зеркало из https://github.com/mozilla/fxa.git
feat(events): Add basic support for account email events
This commit is contained in:
Родитель
01e4522439
Коммит
ff785fa046
|
@ -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([
|
||||
|
|
Загрузка…
Ссылка в новой задаче