Skip to content

Commit 8962ca2

Browse files
authored
Merge pull request #18913 from mozilla/FXA-10372
feat(settings): Enable changing recovery phone
2 parents 57f60c8 + 7d23742 commit 8962ca2

30 files changed

Lines changed: 320 additions & 107 deletions

File tree

libs/accounts/recovery-phone/src/lib/recovery-phone.manager.in.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,10 +186,10 @@ describe('RecoveryPhoneManager', () => {
186186
expect(deleteFromSpy).toBeCalledWith('recoveryPhones');
187187
});
188188

189-
it('should replace a recovery phone', async () => {
189+
it('should change a recovery phone', async () => {
190190
const mockPhone = RecoveryPhoneFactory();
191191

192-
const res = await recoveryPhoneManager.replacePhoneNumber(
192+
const res = await recoveryPhoneManager.changePhoneNumber(
193193
mockPhone.uid.toString('hex'),
194194
mockPhone.phoneNumber,
195195
mockLookUpData

libs/accounts/recovery-phone/src/lib/recovery-phone.manager.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
hasRecoveryCodes,
1414
registerPhoneNumber,
1515
removePhoneNumber,
16-
replacePhoneNumber,
16+
changePhoneNumber,
1717
} from './recovery-phone.repository';
1818
import {
1919
RecoveryNumberAlreadyExistsError,
@@ -124,14 +124,14 @@ export class RecoveryPhoneManager {
124124
* @param phoneNumber The new phone number to replace the existing one
125125
* @param lookupData Lookup data for twilio cross-check
126126
*/
127-
async replacePhoneNumber(
127+
async changePhoneNumber(
128128
uid: string,
129129
phoneNumber: string,
130130
lookupData: PhoneNumberLookupData
131131
): Promise<boolean> {
132132
const uidBuffer = Buffer.from(uid, 'hex');
133133
const now = Date.now();
134-
const results = await replacePhoneNumber(this.db, {
134+
const results = await changePhoneNumber(this.db, {
135135
uid: uidBuffer,
136136
phoneNumber,
137137
lastConfirmed: now,

libs/accounts/recovery-phone/src/lib/recovery-phone.repository.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export async function registerPhoneNumber(
4242
*
4343
* @returns The number of rows updated
4444
*/
45-
export async function replacePhoneNumber(
45+
export async function changePhoneNumber(
4646
db: AccountDatabase,
4747
recoveryPhone: RecoveryPhone
4848
): Promise<number> {

libs/accounts/recovery-phone/src/lib/recovery-phone.service.spec.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ describe('RecoveryPhoneService', () => {
6060
hasRecoveryCodes: jest.fn(),
6161
removeCode: jest.fn(),
6262
getCountByPhoneNumber: jest.fn(),
63-
replacePhoneNumber: jest.fn(),
63+
changePhoneNumber: jest.fn(),
6464
};
6565

6666
const mockOtpManager = {
@@ -922,32 +922,32 @@ describe('RecoveryPhoneService', () => {
922922

923923
describe('replace phone number', () => {
924924
it('replaces code successfully', async () => {
925-
mockRecoveryPhoneManager.replacePhoneNumber.mockResolvedValue(true);
925+
mockRecoveryPhoneManager.changePhoneNumber.mockResolvedValue(true);
926926
mockRecoveryPhoneManager.getUnconfirmed.mockResolvedValueOnce({
927927
isSetup: true,
928928
phoneNumber,
929929
});
930-
const result = await service.replacePhoneNumber(uid, code);
930+
const result = await service.changePhoneNumber(uid, code);
931931

932932
expect(result).toBe(true);
933933
});
934934

935935
it('returns false if there is no existing unconfirmed code', async () => {
936-
mockRecoveryPhoneManager.replacePhoneNumber.mockResolvedValue(true);
936+
mockRecoveryPhoneManager.changePhoneNumber.mockResolvedValue(true);
937937
mockRecoveryPhoneManager.getUnconfirmed.mockResolvedValueOnce({});
938938

939-
const result = await service.replacePhoneNumber(uid, code);
939+
const result = await service.changePhoneNumber(uid, code);
940940

941941
expect(result).toBe(false);
942942
});
943943

944944
it('returns false if existing unconfirmed code is not for setup', async () => {
945-
mockRecoveryPhoneManager.replacePhoneNumber.mockResolvedValue(true);
945+
mockRecoveryPhoneManager.changePhoneNumber.mockResolvedValue(true);
946946
mockRecoveryPhoneManager.getUnconfirmed.mockResolvedValueOnce({
947947
isSetup: false,
948948
phoneNumber,
949949
});
950-
const result = await service.replacePhoneNumber(uid, code);
950+
const result = await service.changePhoneNumber(uid, code);
951951

952952
expect(result).toBe(false);
953953
});

libs/accounts/recovery-phone/src/lib/recovery-phone.service.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,7 @@ export class RecoveryPhoneService {
345345
* @param code
346346
* @returns
347347
*/
348-
public async replacePhoneNumber(uid: string, code: string): Promise<boolean> {
348+
public async changePhoneNumber(uid: string, code: string): Promise<boolean> {
349349
const unconfirmedCode = await this.recoveryPhoneManager.getUnconfirmed(
350350
uid,
351351
code
@@ -363,7 +363,7 @@ export class RecoveryPhoneService {
363363

364364
const lookupData = await this.getPhoneNumberLookupData(phoneNumber);
365365
await this.validateLookupDataForSmsPumping(lookupData);
366-
await this.recoveryPhoneManager.replacePhoneNumber(
366+
await this.recoveryPhoneManager.changePhoneNumber(
367367
uid,
368368
phoneNumber,
369369
lookupData
@@ -562,7 +562,7 @@ export class RecoveryPhoneService {
562562
* Validates that the provided code is for setup and not for sign in.
563563
*
564564
* No further actions are taken; the code is left in redis for completing the
565-
* setup process. This is used in `recovery_phone/replace` and we need
565+
* setup process. This is used in `recovery_phone/change` and we need
566566
* to ensure the code is valid before removing the existing phone number.
567567
*/
568568
public async validateSetupCode(uid: string, code: string) {

packages/functional-tests/pages/settings/components/unitRow.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,17 @@ import { Page } from '@playwright/test';
66
import { ConnectedService } from './connectedService';
77

88
export class UnitRow {
9-
constructor(readonly page: Page, readonly id: string) {}
9+
constructor(
10+
readonly page: Page,
11+
readonly id: string
12+
) {}
1013

1114
get status() {
1215
return this.page.getByTestId(`${this.id}-unit-row-header-value`);
1316
}
1417

1518
async screenshot() {
16-
const el = await this.page.waitForSelector(
17-
`[data-testid=${this.id}-unit-row]`
18-
);
19+
const el = this.page.getByTestId(`${this.id}-unit-row`);
1920
return el.screenshot();
2021
}
2122
}
@@ -93,12 +94,20 @@ export class TotpRow extends UnitRow {
9394
return this.page.getByTestId('two-step-disable-button-unit-row-modal');
9495
}
9596

96-
get changeButton() {
97-
return this.page.getByRole('button', { name: 'Create new codes' });
97+
get getNewBackupCodesButton() {
98+
return this.page.getByTestId('backup-codes-get-new-button');
99+
}
100+
101+
get addCodesButton() {
102+
return this.page.getByTestId('backup-codes-add-button');
98103
}
99104

100105
get addRecoveryPhoneButton() {
101-
return this.page.getByRole('button', { name: 'Add' });
106+
return this.page.getByTestId('recovery-phone-add-button');
107+
}
108+
109+
get changeRecoveryPhoneButton() {
110+
return this.page.getByTestId('recovery-phone-change-button');
102111
}
103112

104113
get removeRecoveryPhoneButton() {

packages/functional-tests/tests/settings/recoveryPhone.spec.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,9 @@ test.describe('severity-1 #smoke', () => {
9191

9292
await recoveryPhone.enterPhoneNumber('1234567890');
9393
await recoveryPhone.clickSendCode();
94-
await expect(recoveryPhone.addErrorBanner).toHaveText(/invalid phone number/i);
94+
await expect(recoveryPhone.addErrorBanner).toHaveText(
95+
/invalid phone number/i
96+
);
9597

9698
await settings.goto();
9799
await settings.disconnectTotp();
@@ -159,6 +161,55 @@ test.describe('severity-1 #smoke', () => {
159161
await settings.disconnectTotp();
160162
});
161163

164+
test('can change recovery phone', async ({
165+
target,
166+
pages: { page, settings, signin, recoveryPhone, totp },
167+
testAccountTracker,
168+
}) => {
169+
const credentials = await signInAccount(
170+
target,
171+
page,
172+
settings,
173+
signin,
174+
testAccountTracker
175+
);
176+
177+
await settings.goto();
178+
await setupRecoveryPhone({
179+
settings,
180+
totp,
181+
recoveryPhone,
182+
page,
183+
credentials,
184+
target,
185+
});
186+
187+
await settings.totp.changeRecoveryPhoneButton.click();
188+
await page.waitForURL(/recovery_phone\/setup/);
189+
190+
await expect(page.getByText('Change recovery phone')).toBeVisible();
191+
192+
await expect(recoveryPhone.addHeader()).toBeVisible();
193+
194+
await recoveryPhone.enterPhoneNumber(getPhoneNumber(target.name));
195+
await recoveryPhone.clickSendCode();
196+
197+
await expect(recoveryPhone.confirmHeader).toBeVisible();
198+
199+
const code = await target.smsClient.getCode(
200+
getPhoneNumber(target.name),
201+
credentials.uid
202+
);
203+
204+
await recoveryPhone.enterCode(code);
205+
await recoveryPhone.clickConfirm();
206+
207+
await page.waitForURL(/settings/);
208+
await expect(settings.alertBar).toHaveText('Recovery phone changed');
209+
210+
await settings.disconnectTotp();
211+
});
212+
162213
test('can sign-in to settings with recovery phone', async ({
163214
target,
164215
pages: {

packages/functional-tests/tests/settings/totpRecoveryCode.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ test.describe('severity-1 #smoke', () => {
8787
await settings.disconnectTotp();
8888
});
8989

90-
test('can change backup authentication codes', async ({
90+
test('can get new backup authentication codes', async ({
9191
target,
9292
pages: {
9393
page,
@@ -109,7 +109,7 @@ test.describe('severity-1 #smoke', () => {
109109

110110
await settings.goto();
111111
const { recoveryCodes } = await addTotp(settings, totp);
112-
await settings.totp.changeButton.click();
112+
await settings.totp.getNewBackupCodesButton.click();
113113

114114
const newCodes = await totp.getRecoveryCodes();
115115
expect(newCodes.some((c) => recoveryCodes.includes(c))).toBe(false);

packages/fxa-auth-client/lib/client.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2211,13 +2211,13 @@ export default class AuthClient {
22112211
* @param code The otp code sent to the user's phone
22122212
* @param headers
22132213
*/
2214-
async recoveryPhoneReplace(
2214+
async recoveryPhoneChange(
22152215
sessionToken: string,
22162216
code: string,
22172217
headers?: Headers
22182218
): Promise<{ nationalFormat?: string }> {
22192219
return this.sessionPost(
2220-
'/recovery_phone/replace',
2220+
'/recovery_phone/change',
22212221
sessionToken,
22222222
{
22232223
code,

packages/fxa-auth-server/lib/routes/recovery-phone.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -526,10 +526,9 @@ class RecoveryPhoneHandler {
526526
throw AppError.invalidOrExpiredOtpCode();
527527
}
528528

529-
async replacePhoneNumber(request: AuthRequest) {
529+
async changePhoneNumber(request: AuthRequest) {
530530
// need to check first that there is an existing phone number
531-
const { uid } = request.auth
532-
.credentials as SessionTokenAuthCredential;
531+
const { uid } = request.auth.credentials as SessionTokenAuthCredential;
533532

534533
const { code } = request.payload as unknown as {
535534
code: string;
@@ -556,7 +555,7 @@ class RecoveryPhoneHandler {
556555
// replace the existing phone number with the new one associated with the code.
557556
let replacedSuccess = false;
558557
try {
559-
replacedSuccess = await this.recoveryPhoneService.replacePhoneNumber(
558+
replacedSuccess = await this.recoveryPhoneService.changePhoneNumber(
560559
uid,
561560
code
562561
);
@@ -579,7 +578,7 @@ class RecoveryPhoneHandler {
579578

580579
if (!replacedSuccess) {
581580
await this.glean.twoStepAuthPhoneReplace.failure(request);
582-
this.statsd.increment('account.recoveryPhone.replacePhoneNumber.failure');
581+
this.statsd.increment('account.recoveryPhone.changePhoneNumber.failure');
583582
recordSecurityEvent('account.recovery_phone_replace_failed', {
584583
db: this.db,
585584
request,
@@ -590,7 +589,7 @@ class RecoveryPhoneHandler {
590589
}
591590

592591
await this.glean.twoStepAuthPhoneReplace.success(request);
593-
this.statsd.increment('account.recoveryPhone.replacePhoneNumber.success');
592+
this.statsd.increment('account.recoveryPhone.changePhoneNumber.success');
594593

595594
const { phoneNumber, nationalFormat } =
596595
await this.recoveryPhoneService.hasConfirmed(uid);
@@ -1009,7 +1008,7 @@ export const recoveryPhoneRoutes = (
10091008
},
10101009
{
10111010
method: 'POST',
1012-
path: '/recovery_phone/replace',
1011+
path: '/recovery_phone/change',
10131012
options: {
10141013
pre: [{ method: featureEnabledCheck }],
10151014
auth: {
@@ -1022,8 +1021,8 @@ export const recoveryPhoneRoutes = (
10221021
},
10231022
},
10241023
handler: function (request: AuthRequest) {
1025-
log.begin('recoveryPhoneReplace', request);
1026-
return recoveryPhoneHandler.replacePhoneNumber(request);
1024+
log.begin('recoveryPhoneChange', request);
1025+
return recoveryPhoneHandler.changePhoneNumber(request);
10271026
},
10281027
},
10291028
{

0 commit comments

Comments
 (0)