Merge pull request #17979 from mozilla/fxa-10341

feat(phone): Add creat methods for recovery phone lib
This commit is contained in:
Vijay Budhram 2024-11-12 11:22:52 -05:00 коммит произвёл GitHub
Родитель bbcfdd0954 80b607482e
Коммит 4fef7bb7ef
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
16 изменённых файлов: 270 добавлений и 13 удалений

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

@ -21,7 +21,16 @@
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/accounts/recovery-phone/jest.config.ts"
"jestConfig": "libs/accounts/recovery-phone/jest.config.ts",
"testPathPattern": ["^(?!.*\\.in\\.spec\\.ts$).*$"]
}
},
"test-integration": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/accounts/recovery-phone/jest.config.ts",
"testPathPattern": ["\\.in\\.spec\\.ts$"]
}
}
}

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

@ -1,4 +1,4 @@
export * from './lib/recovery-phone';
export * from './lib/recovery-phone.manager';
export * from './lib/sms.manager';
export * from './lib/twilio.config';
export * from './lib/twilio.provider';

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

@ -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 { BaseError } from '@fxa/shared/error';
export class RecoveryPhoneError extends BaseError {
constructor(message: string, info: Record<string, any>, cause?: Error) {
super(message, {
name: 'RecoveryPhoneError',
cause,
info,
});
}
}
export class RecoveryNumberInvalidFormatError extends RecoveryPhoneError {
constructor(uid: string, phoneNumber: string, cause?: Error) {
super('Invalid phone number format', { uid, phoneNumber }, cause);
}
}
export class RecoveryNumberAlreadyExistsError extends RecoveryPhoneError {
constructor(uid: string, phoneNumber: string, cause?: Error) {
super('Recovery number already exists', { uid, phoneNumber }, cause);
}
}

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

@ -0,0 +1,28 @@
/* 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/. */
/* 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 { faker } from '@faker-js/faker';
import { RecoveryPhone } from './recovery-phone.types';
export const RecoveryPhoneFactory = (override?: Partial<RecoveryPhone>) => ({
uid: Buffer.from(
faker.string.hexadecimal({
length: 32,
prefix: '',
casing: 'lower',
}),
'hex'
),
phoneNumber: faker.phone.number({ style: 'international' }),
createdAt: Date.now(),
lastConfirmed: Date.now(),
lookupData: JSON.stringify({
a: 'test',
b: 'test2',
}),
...override,
});

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

@ -0,0 +1,79 @@
import { RecoveryPhoneManager } from './recovery-phone.manager';
import {
AccountDatabase,
AccountDbProvider,
testAccountDatabaseSetup,
} from '@fxa/shared/db/mysql/account';
import { Test } from '@nestjs/testing';
import { RecoveryPhoneFactory } from './recovery-phone.factories';
describe('RecoveryPhoneManager', () => {
let recoveryPhoneManager: RecoveryPhoneManager;
let db: AccountDatabase;
beforeAll(async () => {
db = await testAccountDatabaseSetup(['accounts', 'recoveryPhones']);
const moduleRef = await Test.createTestingModule({
providers: [
RecoveryPhoneManager,
{
provide: AccountDbProvider,
useValue: db,
},
],
}).compile();
recoveryPhoneManager = moduleRef.get(RecoveryPhoneManager);
});
afterAll(async () => {
await db.destroy();
});
it('should create a recovery phone', async () => {
const insertIntoSpy = jest.spyOn(db, 'insertInto');
const mockPhone = RecoveryPhoneFactory();
await recoveryPhoneManager.registerPhoneNumber(
mockPhone.uid.toString('hex'),
mockPhone.phoneNumber
);
expect(insertIntoSpy).toBeCalledWith('recoveryPhones');
});
it('should fail for invalid format phone number', async () => {
const mockPhone = RecoveryPhoneFactory();
const phoneNumber = '1234567890a';
await expect(
recoveryPhoneManager.registerPhoneNumber(
mockPhone.uid.toString('hex'),
phoneNumber
)
).rejects.toThrow('Invalid phone number format');
});
it('should fail to register if recovery phone already exists', async () => {
const mockPhone = RecoveryPhoneFactory();
const { uid, phoneNumber } = mockPhone;
await recoveryPhoneManager.registerPhoneNumber(
uid.toString('hex'),
phoneNumber
);
await expect(
recoveryPhoneManager.registerPhoneNumber(uid.toString('hex'), phoneNumber)
).rejects.toThrow('Recovery number already exists');
});
it('should handle database errors gracefully', async () => {
jest.spyOn(db, 'insertInto').mockImplementation(() => {
throw new Error('Database error');
});
const mockPhone = RecoveryPhoneFactory();
const { uid, phoneNumber } = mockPhone;
await expect(
recoveryPhoneManager.registerPhoneNumber(uid.toString('hex'), phoneNumber)
).rejects.toThrow('Database error');
});
});

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

@ -0,0 +1,60 @@
/* 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 {
AccountDatabase,
AccountDbProvider,
} from '@fxa/shared/db/mysql/account';
import { Inject, Injectable } from '@nestjs/common';
import { registerPhoneNumber } from './recovery-phone.repository';
import {
RecoveryNumberAlreadyExistsError,
RecoveryNumberInvalidFormatError,
} from './recovery-phone.errors';
@Injectable()
export class RecoveryPhoneManager {
constructor(
@Inject(AccountDbProvider) private readonly db: AccountDatabase
) {}
private isE164Format(phoneNumber: string) {
const e164Regex = /^\+?[1-9]\d{1,14}$/;
return e164Regex.test(phoneNumber);
}
/**
* Register a phone number for account recovery.
*
* @throws {RecoveryNumberAlreadyExistsError} if the phone number is already registered.
* @param uid
* @param phoneNumber Phone number in E.164 format.
*/
async registerPhoneNumber(uid: string, phoneNumber: string): Promise<any> {
if (!this.isE164Format(phoneNumber)) {
throw new RecoveryNumberInvalidFormatError(uid, phoneNumber);
}
const uidBuffer = Buffer.from(uid, 'hex');
// TODO: Perform phone number validation here via https://www.twilio.com/docs/lookup/v2-api#making-a-request
const lookupData = {};
const now = Date.now();
try {
return await registerPhoneNumber(this.db, {
uid: uidBuffer,
phoneNumber,
createdAt: now,
lastConfirmed: now,
lookupData: JSON.stringify(lookupData),
});
} catch (err) {
if (err.code === 'ER_DUP_ENTRY') {
throw new RecoveryNumberAlreadyExistsError(uid, phoneNumber);
}
throw err;
}
}
}

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

@ -0,0 +1,12 @@
/* 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 { AccountDatabase } from '@fxa/shared/db/mysql/account';
import { RecoveryPhone } from './recovery-phone.types';
export async function registerPhoneNumber(
db: AccountDatabase,
recoveryPhone: RecoveryPhone
) {
return await db.insertInto('recoveryPhones').values(recoveryPhone).execute();
}

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

@ -1,7 +0,0 @@
import { recoveryPhone } from './recovery-phone';
describe('recoveryPhone', () => {
it('should work', () => {
expect(recoveryPhone()).toEqual('recovery-phone');
});
});

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

@ -1,3 +0,0 @@
export function recoveryPhone(): string {
return 'recovery-phone';
}

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

@ -0,0 +1,11 @@
/* 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/. */
export interface RecoveryPhone {
uid: Buffer;
phoneNumber: string;
createdAt: number;
lastConfirmed: number;
lookupData: string;
}

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

@ -219,6 +219,14 @@ export interface RecoveryKeys {
hint: string | null;
}
export interface RecoveryPhones {
createdAt: number;
lastConfirmed: number;
lookupData: Json | null;
phoneNumber: string;
uid: Buffer;
}
export interface SecurityEventNames {
id: Generated<number>;
name: string;
@ -317,6 +325,7 @@ export interface DB {
paypalCustomers: PaypalCustomers;
recoveryCodes: RecoveryCodes;
recoveryKeys: RecoveryKeys;
recoveryPhones: RecoveryPhones;
securityEventNames: SecurityEventNames;
securityEvents: SecurityEvents;
sentEmails: SentEmails;

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

@ -16,6 +16,7 @@ export type ACCOUNT_TABLES =
| 'paypalCustomers'
| 'carts'
| 'recoveryCodes'
| 'recoveryPhones'
| 'emails';
export async function testAccountDatabaseSetup(

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

@ -0,0 +1,8 @@
CREATE TABLE `recoveryPhones` (
`uid` BINARY(16) NOT NULL,
`phoneNumber` VARCHAR(15) NOT NULL,
`createdAt` BIGINT UNSIGNED NOT NULL,
`lastConfirmed` BIGINT UNSIGNED NOT NULL,
`lookupData` JSON,
PRIMARY KEY `uid` (`uid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;

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

@ -0,0 +1,20 @@
SET NAMES utf8mb4 COLLATE utf8mb4_bin;
CALL assertPatchLevel('158');
-- Create the 'recoveryPhone' table.
-- Per spec, the phone number is only inserted if it has been verified.
-- Phone number is in E.164 format and 15 chars max length.
-- `lookupData` contains data from https://www.twilio.com/docs/lookup/v2-api
CREATE TABLE recoveryPhones (
uid BINARY(16) NOT NULL,
phoneNumber VARCHAR(15) NOT NULL,
createdAt BIGINT UNSIGNED NOT NULL,
lastConfirmed BIGINT UNSIGNED NOT NULL,
lookupData JSON,
PRIMARY KEY (uid),
INDEX idx_phoneNumber (phoneNumber),
FOREIGN KEY (uid) REFERENCES accounts(uid) ON DELETE CASCADE
);
UPDATE dbMetadata SET value = '159' WHERE name = 'schema-patch-level';

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

@ -0,0 +1,3 @@
-- DROP TABLE `recoveryPhones`;
-- UPDATE dbMetadata SET value = '158' WHERE name = 'schema-patch-level';

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

@ -1,3 +1,3 @@
{
"level": 158
"level": 159
}