Merge pull request #14399 from mozilla/fxa-5727

feat(events): Add basic support for account email events (sent, delivered, bounced)
This commit is contained in:
Vijay Budhram 2022-11-08 15:53:23 -05:00 коммит произвёл GitHub
Родитель 7a1cbb2c17 ff785fa046
Коммит 540bb71ebb
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
31 изменённых файлов: 609 добавлений и 15 удалений

Просмотреть файл

@ -8,6 +8,7 @@ import {
Totp as TotpType,
RecoveryKeys as RecoveryKeysType,
LinkedAccount as LinkedAccountType,
AccountEvent as AccountEventType,
} from 'fxa-admin-server/src/graphql';
import { AdminPanelFeature } from 'fxa-shared/guards';
import Guard from '../../Guard';
@ -105,6 +106,7 @@ export const Account = ({
query,
securityEvents,
linkedAccounts,
accountEvents,
}: AccountProps) => {
const createdAtDate = getFormattedDate(createdAt);
const disabledAtDate = getFormattedDate(disabledAt);
@ -351,6 +353,23 @@ export const Account = ({
</p>
)}
<h3 className="header-lg">Email History</h3>
{accountEvents && accountEvents.length > 0 ? (
<TableXHeaders rowHeaders={['Event', 'Template', 'Timestamp']}>
{accountEvents.map((accountEvent: AccountEventType) => (
<TableRowXHeader key={accountEvent.createdAt}>
<>{accountEvent.name}</>
<>{accountEvent.template}</>
<>{getFormattedDate(accountEvent.createdAt)}</>
</TableRowXHeader>
))}
</TableXHeaders>
) : (
<p data-testid="account-events" className="result-none">
This account doesn't have any email history.
</p>
)}
<h3 className="header-lg">Linked Accounts</h3>
{linkedAccounts && linkedAccounts.length > 0 ? (
<TableXHeaders rowHeaders={['Event', 'Timestamp', 'Action']}>

Просмотреть файл

@ -107,6 +107,7 @@ class GetAccountsByEmail {
linkedAccounts: [],
attachedClients: [],
subscriptions: [],
accountEvents: [],
},
},
};
@ -180,6 +181,15 @@ class GetAccountsByEmail {
linkedAccounts: [],
securityEvents: [],
subscriptions: [],
accountEvents: [
{
name: 'emailSent',
createdAt: new Date(Date.now() - 60 * 60 * 1e3).getTime(),
template: 'recovery',
eventType: 'emailEvent',
service: 'sync',
},
],
},
},
};

Просмотреть файл

@ -52,6 +52,13 @@ const ACCOUNT_SCHEMA = `
authAt
enabled
}
accountEvents {
name
service
eventType
createdAt
template
}
attachedClients {
createdTime
createdTimeFormatted

Просмотреть файл

@ -4,9 +4,10 @@
import { Module } from '@nestjs/common';
import { AuthClientFactory, AuthClientService } from './auth-client.service';
import { FirestoreFactory, FirestoreService } from './firestore.service';
@Module({
providers: [AuthClientFactory],
exports: [AuthClientService],
providers: [AuthClientFactory, FirestoreFactory],
exports: [AuthClientService, FirestoreService],
})
export class BackendModule {}

Просмотреть файл

@ -5,6 +5,7 @@
import { Firestore } from '@google-cloud/firestore';
import { Test, TestingModule } from '@nestjs/testing';
import { MockConfig, MockFirestoreFactory } from '../mocks';
import { FirestoreService } from './firestore.service';
describe('Firestore Service', () => {
let service: Firestore;
@ -14,7 +15,7 @@ describe('Firestore Service', () => {
providers: [MockConfig, MockFirestoreFactory],
}).compile();
service = module.get<Firestore>('FIRESTORE');
service = module.get<Firestore>(FirestoreService);
});
it('should be defined', () => {

Просмотреть файл

@ -42,8 +42,9 @@ export function setupFirestore(config: FirebaseFirestore.Settings) {
/**
* Factory for providing access to firestore
*/
export const FirestoreService = Symbol('FIRESTORE');
export const FirestoreFactory: Provider<Firestore> = {
provide: 'FIRESTORE',
provide: FirestoreService,
useFactory: (configService: ConfigService) => {
const firestoreConfig = configService.get('authFirestore');
if (firestoreConfig == null) {

Просмотреть файл

@ -42,6 +42,7 @@ import { EventLoggingService } from '../../event-logging/event-logging.service';
import { SubscriptionsService } from '../../subscriptions/subscriptions.service';
import { AccountResolver } from './account.resolver';
import { AuthClientService } from '../../backend/auth-client.service';
import { FirestoreService } from '../../backend/firestore.service';
import { BasketService } from '../../newsletters/basket.service';
export const chance = new Chance();
@ -94,6 +95,7 @@ describe('AccountResolver', () => {
};
let authClient: any;
let basketService: any;
let firestoreService: any;
beforeAll(async () => {
knex = await testDatabaseSetup();
@ -170,6 +172,37 @@ describe('AccountResolver', () => {
useValue: authClient,
};
// Not ideal, but we have to do this because
// of the nested nature of the Firestore documents
const get = () =>
Promise.resolve({
docs: [
{
data: () => ({
name: 'emailSent',
createdAt: Date.now(),
eventType: 'emailEvent',
template: 'recovery',
flowId: 'flowId',
service: 'service',
}),
},
],
});
const limit = () => ({ get });
const collection = () => ({
orderBy: () => ({ limit }),
doc: () => ({ collection }),
limit,
get,
});
firestoreService = { collection };
const MockFirestoreService = {
provide: FirestoreService,
useValue: firestoreService,
};
const module: TestingModule = await Test.createTestingModule({
providers: [
AccountResolver,
@ -180,6 +213,7 @@ describe('AccountResolver', () => {
MockSubscription,
MockBasket,
MockDb,
MockFirestoreService,
MockAuthClient,
],
}).compile();
@ -418,4 +452,15 @@ describe('AccountResolver', () => {
expect(result).toBeFalsy();
});
});
it('loads account events', async () => {
const user = (await resolver.accountByEmail(
USER_1.email,
true,
'joe'
)) as Account;
const result = await resolver.accountEvents(user);
expect(result).toBeDefined();
expect(result.length).toBe(1);
});
});

Просмотреть файл

@ -12,6 +12,7 @@ import {
Resolver,
Root,
} from '@nestjs/graphql';
import { Firestore } from '@google-cloud/firestore';
import {
ClientFormatter,
ConnectedServicesFactory,
@ -35,8 +36,10 @@ import {
import { Account as AccountType } from '../../gql/model/account.model';
import { AttachedClient } from '../../gql/model/attached-clients.model';
import { Email as EmailType } from '../../gql/model/emails.model';
import { AccountEvent as AccountEventType } from '../../gql/model/account-events.model';
import { SubscriptionsService } from '../../subscriptions/subscriptions.service';
import { AuthClientService } from '../../backend/auth-client.service';
import { FirestoreService } from '../../backend/firestore.service';
import AuthClient from 'fxa-auth-client';
import { BasketService } from '../../newsletters/basket.service';
@ -91,7 +94,8 @@ export class AccountResolver {
private configService: ConfigService<AppConfig>,
private eventLogging: EventLoggingService,
private basketService: BasketService,
@Inject(AuthClientService) private authAPI: AuthClient
@Inject(AuthClientService) private authAPI: AuthClient,
@Inject(FirestoreService) private firestore: Firestore
) {}
@Features(AdminPanelFeature.AccountSearch)
@ -279,6 +283,21 @@ export class AccountResolver {
.orderBy('createdAt', 'DESC');
}
@Features(AdminPanelFeature.AccountSearch)
@ResolveField()
public async accountEvents(@Root() account: Account) {
// Not sure the best way for admin panel to get this config from event broker config
const eventsDbRef = this.firestore.collection('fxa-eb-users');
const queryResult = await eventsDbRef
.doc(account.uid)
.collection('events')
.orderBy('createdAt', 'desc')
.limit(100)
.get();
return queryResult.docs.map((doc) => doc.data() as AccountEventType);
}
@Features(AdminPanelFeature.AccountSearch)
@ResolveField()
public async totp(@Root() account: Account) {

Просмотреть файл

@ -0,0 +1,27 @@
/* 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 { Field, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class AccountEvent {
@Field({ nullable: true })
public name!: string;
@Field({ nullable: true })
public createdAt!: number;
@Field({ nullable: true })
eventType!: string;
// Email event based properties
@Field({ nullable: true })
template!: string;
// Metrics properties
@Field({ nullable: true })
flowId!: string;
@Field({ nullable: true })
service!: string;
}

Просмотреть файл

@ -10,6 +10,7 @@ import { RecoveryKeys } from './recovery-keys.model';
import { SecurityEvents } from './security-events.model';
import { Totp } from './totp.model';
import { LinkedAccount } from './linked-account.model';
import { AccountEvent } from './account-events.model';
import { MozSubscription } from './moz-subscription.model';
@ObjectType()
@ -58,4 +59,7 @@ export class Account {
@Field((type) => [LinkedAccount], { nullable: true })
public linkedAccounts!: LinkedAccount[];
@Field((type) => [AccountEvent], { nullable: true })
public accountEvents!: AccountEvent[];
}

Просмотреть файл

@ -113,6 +113,15 @@ export interface LinkedAccount {
enabled: boolean;
}
export interface AccountEvent {
name?: Nullable<string>;
createdAt?: Nullable<number>;
eventType?: Nullable<string>;
template?: Nullable<string>;
flowId?: Nullable<string>;
service?: Nullable<string>;
}
export interface MozSubscription {
created: number;
currentPeriodEnd: number;
@ -144,6 +153,7 @@ export interface Account {
attachedClients?: Nullable<AttachedClient[]>;
subscriptions?: Nullable<MozSubscription[]>;
linkedAccounts?: Nullable<LinkedAccount[]>;
accountEvents?: Nullable<AccountEvent[]>;
}
export interface RelyingParty {

Просмотреть файл

@ -7,6 +7,7 @@ import { ConfigService } from '@nestjs/config';
import { Path } from 'convict';
import { MozLoggerService } from 'fxa-shared/nestjs/logger/logger.service';
import config, { AppConfig } from './config';
import { FirestoreService } from './backend/firestore.service';
export const mockConfigOverrides: any = {};
export const MockConfig: Provider = {
@ -29,7 +30,7 @@ export const MockMetricsFactory: Provider = {
export const mockFirestoreCollection = jest.fn();
export const MockFirestoreFactory: Provider = {
provide: 'FIRESTORE',
provide: FirestoreService,
useFactory: () => {
return {
collection: mockFirestoreCollection,

Просмотреть файл

@ -108,6 +108,15 @@ enum ProviderId {
APPLE
}
type AccountEvent {
name: String
createdAt: Float
eventType: String
template: String
flowId: String
service: String
}
type MozSubscription {
created: Float!
currentPeriodEnd: Float!
@ -139,6 +148,7 @@ type Account {
attachedClients: [AttachedClient!]
subscriptions: [MozSubscription!]
linkedAccounts: [LinkedAccount!]
accountEvents: [AccountEvent!]
}
type RelyingParty {

Просмотреть файл

@ -10,7 +10,7 @@ import {
AppStorePurchaseManagerService,
AppStoreService,
} from './appstore.service';
import { FirestoreFactory } from './firestore.service';
import { FirestoreFactory } from '../backend/firestore.service';
describe('AppStoreHelperService', () => {
let service: AppStoreHelperService;

Просмотреть файл

@ -9,6 +9,7 @@ import { MozLoggerService } from 'fxa-shared/nestjs/logger/logger.service';
import { AppStoreHelper } from 'fxa-shared/payments/iap/apple-app-store/app-store-helper';
import { PurchaseManager } from 'fxa-shared/payments/iap/apple-app-store/purchase-manager';
import { AppConfig } from '../config';
import { FirestoreService } from '../backend/firestore.service';
/**
* Extends AppStoreHelper to be service like
@ -30,7 +31,7 @@ export class AppStorePurchaseManagerService extends PurchaseManager {
appStoreHelper: AppStoreHelperService,
configService: ConfigService<AppConfig>,
logger: MozLoggerService,
@Inject('FIRESTORE') firestore: Firestore
@Inject(FirestoreService) firestore: Firestore
) {
const config = {
authFirestore: configService.get('authFirestore'),

Просмотреть файл

@ -9,6 +9,7 @@ import { MozLoggerService } from 'fxa-shared/nestjs/logger/logger.service';
import { PurchaseManager } from 'fxa-shared/payments/iap/google-play/purchase-manager';
import { UserManager } from 'fxa-shared/payments/iap/google-play/user-manager';
import { Auth, google } from 'googleapis';
import { FirestoreService } from '../backend/firestore.service';
/**
* Extends PurchaseManager to be service like
@ -17,7 +18,7 @@ import { Auth, google } from 'googleapis';
export class PlayStorePurchaseManagerService extends PurchaseManager {
constructor(
configService: ConfigService,
@Inject('FIRESTORE') firestore: Firestore,
@Inject(FirestoreService) firestore: Firestore,
logger: MozLoggerService
) {
const prefix = `${configService.get('authFirestore.prefix')}iap-`;
@ -53,7 +54,7 @@ export class PlayStoreUserManagerService extends UserManager {
configService: ConfigService,
logger: MozLoggerService,
purchaseManager: PlayStorePurchaseManagerService,
@Inject('FIRESTORE') firestore: Firestore
@Inject(FirestoreService) firestore: Firestore
) {
const prefix = `${configService.get('authFirestore.prefix')}iap-`;
const purchasesDbRef = firestore.collection(`${prefix}play-purchases`);

Просмотреть файл

@ -25,6 +25,7 @@ import {
import { StatsD } from 'hot-shots';
import Stripe from 'stripe';
import { AppConfig } from '../config';
import { FirestoreService } from '../backend/firestore.service';
export const StripeFactory: Provider<Stripe> = {
provide: 'STRIPE',
@ -46,7 +47,7 @@ export class StripePaymentConfigManagerService extends PaymentConfigManager {
constructor(
configService: ConfigService<AppConfig>,
logger: MozLoggerService,
@Inject('FIRESTORE') firestore: Firestore
@Inject(FirestoreService) firestore: Firestore
) {
const config = {
subscriptions: configService.get('subscriptions'),
@ -64,7 +65,7 @@ export class StripePaymentConfigManagerService extends PaymentConfigManager {
export class StripeFirestoreService extends StripeFirestore {
constructor(
configService: ConfigService<AppConfig>,
@Inject('FIRESTORE') firestore: Firestore,
@Inject(FirestoreService) firestore: Firestore,
@Inject('STRIPE') stripe: Stripe
) {
const config = {

Просмотреть файл

@ -9,7 +9,7 @@ import {
AppStorePurchaseManagerService,
AppStoreService,
} from './appstore.service';
import { FirestoreFactory } from './firestore.service';
import { FirestoreFactory } from '../backend/firestore.service';
import {
PlayStorePurchaseManagerService,
PlayStoreService,

Просмотреть файл

@ -28,6 +28,7 @@ const {
} = require('../lib/types');
const { setupFirestore } = require('../lib/firestore-db');
const { AppleIAP } = require('../lib/payments/iap/apple-app-store/apple-iap');
const { AccountEventsManager } = require('../lib/account-events');
async function run(config) {
Container.set(AppConfig, config);
@ -59,6 +60,13 @@ async function run(config) {
Container.set(AuthFirestore, authFirestore);
}
const accountEventsManager = config.accountEvents.enabled
? new AccountEventsManager()
: {
recordEmailEvent: async () => Promise.resolve(),
};
Container.set(AccountEventsManager, accountEventsManager);
const redis = require('../lib/redis')(
{ ...config.redis, ...config.redis.sessionTokens },
log

Просмотреть файл

@ -97,6 +97,12 @@ const convictConf = convict({
env: 'AUTH_FIRESTORE_PROJECT_ID',
format: String,
},
ebPrefix: {
default: 'fxa-eb-',
doc: 'Event broker Firestore collection prefix',
env: 'AUTH_EB_FIRESTORE_COLLECTION_PREFIX',
format: String,
},
},
pubsub: {
audience: {
@ -1953,6 +1959,14 @@ const convictConf = convict({
},
},
tracing: tracingConfig,
accountEvents: {
enabled: {
default: true,
doc: 'Flag to enable account event logging. Currently only email based events',
env: 'ACCOUNT_EVENTS_ENABLED',
format: Boolean,
},
},
});
// handle configuration files. you can specify a CSV list of configuration

Просмотреть файл

@ -0,0 +1,86 @@
/* 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 { Firestore } from '@google-cloud/firestore';
import { Container } from 'typedi';
import { AuthFirestore, AppConfig } from '../lib/types';
import { StatsD } from 'hot-shots';
type AccountEvent = {
name: string;
createdAt: number;
eventType: 'emailEvent' | 'securityEvent';
// Email event properties
template?: string;
// General metric properties
deviceId?: string;
flowId?: string;
service?: string;
};
export class AccountEventsManager {
private firestore: Firestore;
private usersDbRef;
private statsd;
readonly prefix: string;
readonly name: string;
constructor() {
// Users are already stored in the event broker Firebase collection, so we
// need to grab that prefix.
const { authFirestore } = Container.get(AppConfig);
this.prefix = authFirestore.ebPrefix;
this.name = `${this.prefix}users`;
this.firestore = Container.get(AuthFirestore);
this.usersDbRef = this.firestore.collection(this.name);
this.statsd = Container.get(StatsD);
}
/**
* Records a new email event for the user.
*/
public async recordEmailEvent(
uid: string,
message: AccountEvent,
name: 'emailSent' | 'emailDelivered' | 'emailBounced' | 'emailComplaint'
) {
try {
const { template, deviceId, flowId, service } = message;
const emailEvent = {
name,
createdAt: Date.now(),
eventType: 'emailEvent',
template,
deviceId,
flowId,
service,
};
// Firestore can be configured to ignore undefined keys, but we do it here
// since it is a global config
const filteredEmailEvent = {};
Object.keys(emailEvent).forEach((key) => {
// @ts-ignore
if (emailEvent[key]) {
// @ts-ignore
filteredEmailEvent[key] = emailEvent[key];
}
});
await this.usersDbRef
.doc(uid)
.collection('events')
.add(filteredEmailEvent);
this.statsd.increment('accountEvents.recordEmailEvent.write');
} catch (err) {
// Failing to write to events shouldn't break anything
this.statsd.increment('accountEvents.recordEmailEvent.error');
}
}
}

Просмотреть файл

@ -144,6 +144,8 @@ module.exports = function (log, error) {
// Log the bounced flowEvent and emailEvent metrics
utils.logFlowEventFromMessage(log, message, 'bounced');
utils.logEmailEventFromMessage(log, message, 'bounced', emailDomain);
utils.logAccountEventFromMessage(message, 'emailBounced');
log.info('handleBounce', logData);
/**

Просмотреть файл

@ -39,6 +39,7 @@ module.exports = function (log) {
// Log the delivery flowEvent and emailEvent metrics if available
utils.logFlowEventFromMessage(log, message, 'delivered');
utils.logEmailEventFromMessage(log, message, 'delivered', emailDomain);
utils.logAccountEventFromMessage(message, 'emailDelivered');
log.info('handleDelivery', logData);
}

Просмотреть файл

@ -8,8 +8,10 @@ const ROOT_DIR = '../../..';
const config = require(`${ROOT_DIR}/config`);
const emailDomains = require(`${ROOT_DIR}/config/popular-email-domains`);
const { default: Container } = require('typedi');
const { AccountEventsManager } = require('../../account-events');
let amplitude;
let amplitude, accountEventsManager;
function getInsensitiveHeaderValueFromArray(headerName, headers) {
let value = '';
@ -153,6 +155,27 @@ function logAmplitudeEvent(log, message, eventInfo) {
);
}
function logAccountEventFromMessage(message, type) {
// Log email metrics to Firestore
const templateName = getHeaderValue('X-Template-Name', message);
const flowId = getHeaderValue('X-Flow-Id', message);
const uid = getHeaderValue('X-Uid', message);
const service = getHeaderValue('X-Service-Id', message);
if (uid && Container.has(AccountEventsManager)) {
accountEventsManager = Container.get(AccountEventsManager);
accountEventsManager.recordEmailEvent(
uid,
{
template: templateName,
flowId,
service,
},
type
);
}
}
function logEmailEventFromMessage(log, message, type, emailDomain) {
const templateName = getHeaderValue('X-Template-Name', message);
const templateVersion = getHeaderValue('X-Template-Version', message);
@ -241,6 +264,7 @@ module.exports = {
logEmailEventFromMessage,
logErrorIfHeadersAreWeirdOrMissing,
logFlowEventFromMessage,
logAccountEventFromMessage,
getHeaderValue,
getAnonymizedEmailDomain,
};

Просмотреть файл

@ -410,6 +410,7 @@ module.exports = function (
const ip = request.app.clientAddress;
request.emitMetricsEvent('session.resend_code');
const metricsContext = await request.gatherMetricsContext({});
// Check to see if this account has a verified TOTP token. If so, then it should
// not be allowed to bypass TOTP requirement by sending a sign-in confirmation email.
@ -444,6 +445,9 @@ module.exports = function (
uaOSVersion: sessionToken.uaOSVersion,
uaDeviceType: sessionToken.uaDeviceType,
uid: sessionToken.uid,
flowId: metricsContext.flow_id,
flowBeginTime: metricsContext.flowBeginTime,
deviceId: metricsContext.device_id,
};
if (account.primaryEmail.isVerified) {

Просмотреть файл

@ -486,6 +486,15 @@ module.exports = function (log, config, bounces) {
headers,
});
emailUtils.logAccountEventFromMessage(
{
headers: {
...headers,
},
},
'emailSent'
);
return resolve(status);
});
});

Просмотреть файл

@ -0,0 +1,114 @@
/* 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/. */
'use strict';
const sinon = require('sinon');
const assert = { ...sinon.assert, ...require('chai').assert };
const { StatsD } = require('hot-shots');
const { AccountEventsManager } = require('../../lib/account-events');
const { default: Container } = require('typedi');
const { AppConfig, AuthFirestore } = require('../../lib/types');
const UID = 'uid';
describe('Account Events', () => {
let usersDbRefMock;
let firestore;
let accountEventsManager;
let addMock;
let statsd;
beforeEach(() => {
addMock = sinon.stub();
usersDbRefMock = {
doc: sinon.stub().returns({
collection: sinon.stub().returns({
add: addMock,
}),
}),
};
firestore = {
collection: sinon.stub().returns(usersDbRefMock),
};
const mockConfig = {
authFirestore: {
enabled: true,
ebPrefix: 'fxa-eb-',
},
accountEvents: {
enabled: true,
},
};
Container.set(AppConfig, mockConfig);
Container.set(AuthFirestore, firestore);
statsd = { increment: sinon.spy() };
Container.set(StatsD, statsd);
accountEventsManager = new AccountEventsManager();
});
afterEach(() => {
Container.reset();
});
it('can be instantiated', () => {
assert.ok(accountEventsManager);
});
describe('email events', function () {
it('can record email event', async () => {
const message = {
template: 'verifyLoginCode',
deviceId: 'deviceId',
flowId: 'flowId',
service: 'service',
};
await accountEventsManager.recordEmailEvent(UID, message, 'emailSent');
const assertMessage = {
...message,
eventType: 'emailEvent',
name: 'emailSent',
};
assert.calledOnceWithMatch(addMock, assertMessage);
assert.calledOnceWithExactly(usersDbRefMock.doc, UID);
assert.isAtLeast(Date.now(), addMock.firstCall.firstArg.createdAt);
assert.calledOnceWithExactly(
statsd.increment,
'accountEvents.recordEmailEvent.write'
);
});
it('logs and does not throw on failure', async () => {
usersDbRefMock.doc = sinon.stub().throws();
const message = {
template: 'verifyLoginCode',
deviceId: 'deviceId',
flowId: 'flowId',
service: 'service',
};
await accountEventsManager.recordEmailEvent(UID, message, 'emailSent');
assert.isFalse(addMock.called);
assert.calledOnceWithExactly(
statsd.increment,
'accountEvents.recordEmailEvent.error'
);
});
it('strips falsy values', async () => {
const message = {
template: null,
deviceId: undefined,
flowId: '',
};
await accountEventsManager.recordEmailEvent(UID, message, 'emailSent');
assert.isTrue(addMock.called);
assert.isUndefined(addMock.firstCall.firstArg.template);
assert.isUndefined(addMock.firstCall.firstArg.deviceId);
assert.isUndefined(addMock.firstCall.firstArg.flowId);
});
});
});

Просмотреть файл

@ -14,6 +14,7 @@ const { mockLog } = require('../../mocks');
const sinon = require('sinon');
const { default: Container } = require('typedi');
const { StripeHelper } = require('../../../lib/payments/stripe');
const emailHelpers = require('../../../lib/email/utils/helpers');
const mockBounceQueue = new EventEmitter();
mockBounceQueue.start = function start() {};
@ -663,4 +664,45 @@ describe('bounce messages', () => {
assert.equal(log.info.args[1][1].domain, 'aol.com');
});
});
it('should log account email event (emailDelivered)', async () => {
const stub = sinon
.stub(emailHelpers, 'logAccountEventFromMessage')
.returns(Promise.resolve());
const mockMsg = mockMessage({
bounce: {
bounceType: 'Permanent',
bounceSubType: 'General',
bouncedRecipients: [{ emailAddress: 'test@aol.com' }],
},
mail: {
headers: [
{
name: 'X-Template-Name',
value: 'verifyLoginEmail',
},
{
name: 'X-Flow-Id',
value: 'someFlowId',
},
{
name: 'X-Flow-Begin-Time',
value: '1234',
},
{
name: 'X-Uid',
value: 'en',
},
],
},
});
await mockedBounces(log, mockDB).handleBounce(mockMsg);
sinon.assert.calledOnceWithExactly(
emailHelpers.logAccountEventFromMessage,
mockMsg,
'emailBounced'
);
stub.restore();
});
});

Просмотреть файл

@ -9,13 +9,15 @@ const { assert } = require('chai');
const EventEmitter = require('events').EventEmitter;
const { mockLog } = require('../../mocks');
const sinon = require('sinon');
const emailHelpers = require('../../../lib/email/utils/helpers');
const delivery = require('../../../lib/email/delivery');
let sandbox;
const mockDeliveryQueue = new EventEmitter();
mockDeliveryQueue.start = function start() {};
function mockMessage(msg) {
msg.del = sinon.spy();
msg.del = sandbox.spy();
msg.headers = {};
return msg;
}
@ -25,6 +27,14 @@ function mockedDelivery(log) {
}
describe('delivery messages', () => {
beforeEach(() => {
sandbox = sinon.createSandbox();
});
afterEach(() => {
sandbox.restore();
});
it('should not log an error for headers', () => {
const log = mockLog();
return mockedDelivery(log)
@ -206,4 +216,49 @@ describe('delivery messages', () => {
assert.equal(log.info.args[1][1].domain, 'aol.com');
});
});
it('should log account email event (emailDelivered)', async () => {
sandbox
.stub(emailHelpers, 'logAccountEventFromMessage')
.returns(Promise.resolve());
const log = mockLog();
const mockMsg = mockMessage({
notificationType: 'Delivery',
delivery: {
timestamp: '2016-01-27T14:59:38.237Z',
recipients: ['jane@aol.com'],
processingTimeMillis: 546,
reportingMTA: 'a8-70.smtp-out.amazonses.com',
smtpResponse: '250 ok: Message 64111812 accepted',
remoteMtaIp: '127.0.2.0',
},
mail: {
headers: [
{
name: 'X-Template-Name',
value: 'verifyLoginEmail',
},
{
name: 'X-Flow-Id',
value: 'someFlowId',
},
{
name: 'X-Flow-Begin-Time',
value: '1234',
},
{
name: 'X-Uid',
value: 'en',
},
],
},
});
await mockedDelivery(log).handleDelivery(mockMsg);
sinon.assert.calledOnceWithExactly(
emailHelpers.logAccountEventFromMessage,
mockMsg,
'emailDelivered'
);
});
});

Просмотреть файл

@ -10,6 +10,8 @@ const { assert } = require('chai');
const { mockLog } = require('../../mocks');
const proxyquire = require('proxyquire');
const sinon = require('sinon');
const { default: Container } = require('typedi');
const { AccountEventsManager } = require('../../../lib/account-events');
const amplitude = sinon.spy();
const emailHelpers = proxyquire(`${ROOT_DIR}/lib/email/utils/helpers`, {
@ -337,4 +339,72 @@ describe('email utils helpers', () => {
assert.equal(log.warn.callCount, 0);
});
});
describe('logAccountEventFromMessage', () => {
let mockAccountEventsManager;
beforeEach(() => {
mockAccountEventsManager = {
recordEmailEvent: sinon.stub(),
};
Container.set(AccountEventsManager, mockAccountEventsManager);
});
afterEach(() => {
Container.reset();
});
it('should call account events manager from valid message', async () => {
emailHelpers.logAccountEventFromMessage(
{
headers: [
{ name: 'X-Template-Name', value: 'recovery' },
{ name: 'X-Flow-Id', value: 'flowId' },
{ name: 'X-Uid', value: 'uid' },
{ name: 'X-Service-Id', value: 'service' },
],
},
'emailBounced'
);
sinon.assert.calledOnceWithExactly(
mockAccountEventsManager.recordEmailEvent,
'uid',
{
template: 'recovery',
flowId: 'flowId',
service: 'service',
},
'emailBounced'
);
});
it('ignores if no uid', async () => {
emailHelpers.logAccountEventFromMessage(
{
headers: [
{ name: 'X-Template-Name', value: 'recovery' },
{ name: 'X-Flow-Id', value: 'flowId' },
{ name: 'X-Service-Id', value: 'service' },
],
},
'emailBounced'
);
sinon.assert.notCalled(mockAccountEventsManager.recordEmailEvent);
});
it('not called if firestore disable', async () => {
Container.remove(AccountEventsManager);
emailHelpers.logAccountEventFromMessage(
{
headers: [
{ name: 'X-Template-Name', value: 'recovery' },
{ name: 'X-Flow-Id', value: 'flowId' },
{ name: 'X-Uid', value: 'uid' },
{ name: 'X-Service-Id', value: 'service' },
],
},
'emailBounced'
);
sinon.assert.notCalled(mockAccountEventsManager.recordEmailEvent);
});
});
});

Просмотреть файл

@ -10,6 +10,8 @@ const getRoute = require('../routes_helpers').getRoute;
const mocks = require('../mocks');
const proxyquire = require('proxyquire');
const uuid = require('uuid');
const { default: Container } = require('typedi');
const { ProfileClient } = require('../../lib/types');
const TEST_EMAIL = 'foo@gmail.com';
const MS_ONE_DAY = 1000 * 60 * 60 * 24;
@ -98,6 +100,7 @@ describe('IP Profiling', function () {
keys: 'true',
},
});
Container.set(ProfileClient, {});
accountRoutes = makeRoutes({
db: mockDB,
mailer: mockMailer,
@ -105,6 +108,10 @@ describe('IP Profiling', function () {
route = getRoute(accountRoutes, '/account/login');
});
afterEach(() => {
Container.remove(ProfileClient);
});
it('no previously verified session', () => {
mockDB.securityEvents = function () {
return Promise.resolve([