Skip to content

Commit 519bee0

Browse files
authored
Merge pull request #18888 from mozilla/FXA-11668
feat(2fa): Add new recovery_phone/replace route
2 parents 4439a6c + 8bf2415 commit 519bee0

17 files changed

Lines changed: 748 additions & 43 deletions

File tree

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,16 @@ export class RecoveryNumberRemoveMissingBackupCodes extends RecoveryPhoneError {
5454
}
5555
}
5656

57+
export class RecoveryNumberReplaceNotExistsError extends RecoveryPhoneError {
58+
constructor(uid: string, cause?: Error) {
59+
super(
60+
'Existing recovery number does not exist for replacing.',
61+
{ uid },
62+
cause
63+
);
64+
}
65+
}
66+
5767
export class SmsSendRateLimitExceededError extends RecoveryPhoneError {
5868
constructor(
5969
uid: string,
@@ -99,4 +109,4 @@ export class MessageBodyTooLong extends RecoveryPhoneError {
99109
encoding,
100110
});
101111
}
102-
}
112+
}

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

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

189+
it('should replace a recovery phone', async () => {
190+
const mockPhone = RecoveryPhoneFactory();
191+
192+
const res = await recoveryPhoneManager.replacePhoneNumber(
193+
mockPhone.uid.toString('hex'),
194+
mockPhone.phoneNumber,
195+
mockLookUpData
196+
);
197+
198+
expect(res).toBe(true);
199+
});
200+
189201
it('should handle database errors gracefully', async () => {
190202
const insertIntoSpy = jest
191203
.spyOn(db, 'insertInto')
@@ -321,9 +333,8 @@ describe('RecoveryPhoneManager', () => {
321333
})
322334
.execute();
323335

324-
const result = await recoveryPhoneManager.getCountByPhoneNumber(
325-
phoneNumber
326-
);
336+
const result =
337+
await recoveryPhoneManager.getCountByPhoneNumber(phoneNumber);
327338

328339
expect(result).toBe(2);
329340
});

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@ import {
1313
hasRecoveryCodes,
1414
registerPhoneNumber,
1515
removePhoneNumber,
16+
replacePhoneNumber,
1617
} from './recovery-phone.repository';
1718
import {
1819
RecoveryNumberAlreadyExistsError,
1920
RecoveryNumberInvalidFormatError,
2021
RecoveryNumberNotExistsError,
22+
RecoveryNumberReplaceNotExistsError,
2123
} from './recovery-phone.errors';
2224
import { Redis } from 'ioredis';
2325
import { PhoneNumberInstance } from 'twilio/lib/rest/lookups/v2/phoneNumber';
@@ -115,6 +117,34 @@ export class RecoveryPhoneManager {
115117
return true;
116118
}
117119

120+
/**
121+
* Replaces an existing phone number with a new one.
122+
*
123+
* @param uid The user's unique identifier
124+
* @param phoneNumber The new phone number to replace the existing one
125+
* @param lookupData Lookup data for twilio cross-check
126+
*/
127+
async replacePhoneNumber(
128+
uid: string,
129+
phoneNumber: string,
130+
lookupData: PhoneNumberLookupData
131+
): Promise<boolean> {
132+
const uidBuffer = Buffer.from(uid, 'hex');
133+
const now = Date.now();
134+
const results = await replacePhoneNumber(this.db, {
135+
uid: uidBuffer,
136+
phoneNumber,
137+
lastConfirmed: now,
138+
createdAt: now,
139+
lookupData: JSON.stringify(lookupData),
140+
});
141+
142+
if (results < 1) {
143+
throw new RecoveryNumberReplaceNotExistsError(uid);
144+
}
145+
return true;
146+
}
147+
118148
/**
119149
* Store phone number data and SMS code for a user.
120150
*

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,24 @@ export async function registerPhoneNumber(
3737
await db.insertInto('recoveryPhones').values(recoveryPhone).execute();
3838
}
3939

40+
/**
41+
* Updates a recoveryPhones record with the new data. The original `createdAt` is not preserved.
42+
*
43+
* @returns The number of rows updated
44+
*/
45+
export async function replacePhoneNumber(
46+
db: AccountDatabase,
47+
recoveryPhone: RecoveryPhone
48+
): Promise<number> {
49+
const result = await db
50+
.updateTable('recoveryPhones')
51+
.where('uid', '=', recoveryPhone.uid)
52+
.set(recoveryPhone)
53+
.execute();
54+
55+
return result.length;
56+
}
57+
4058
export async function removePhoneNumber(
4159
db: AccountDatabase,
4260
uid: Buffer

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

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

6566
const mockOtpManager = {
@@ -886,4 +887,69 @@ describe('RecoveryPhoneService', () => {
886887
});
887888
});
888889
});
890+
891+
describe('validate setup code', () => {
892+
it('returns true for a valid setup code', async () => {
893+
mockRecoveryPhoneManager.getUnconfirmed.mockResolvedValueOnce({
894+
isSetup: true,
895+
});
896+
const result = await service.validateSetupCode(uid, code);
897+
898+
expect(result).toBe(true);
899+
expect(mockRecoveryPhoneManager.getUnconfirmed).toBeCalledTimes(1);
900+
expect(mockRecoveryPhoneManager.getUnconfirmed).toBeCalledWith(uid, code);
901+
});
902+
it('returns false if data is null', async () => {
903+
mockRecoveryPhoneManager.getUnconfirmed.mockResolvedValueOnce(null);
904+
const result = await service.validateSetupCode(uid, code);
905+
906+
expect(result).toBe(false);
907+
expect(mockRecoveryPhoneManager.getUnconfirmed).toBeCalledTimes(1);
908+
expect(mockRecoveryPhoneManager.getUnconfirmed).toBeCalledWith(uid, code);
909+
});
910+
911+
it('returns false if data is not a setup code', async () => {
912+
mockRecoveryPhoneManager.getUnconfirmed.mockResolvedValueOnce({
913+
isSetup: false,
914+
});
915+
const result = await service.validateSetupCode(uid, code);
916+
917+
expect(result).toBe(false);
918+
expect(mockRecoveryPhoneManager.getUnconfirmed).toBeCalledTimes(1);
919+
expect(mockRecoveryPhoneManager.getUnconfirmed).toBeCalledWith(uid, code);
920+
});
921+
});
922+
923+
describe('replace phone number', () => {
924+
it('replaces code successfully', async () => {
925+
mockRecoveryPhoneManager.replacePhoneNumber.mockResolvedValue(true);
926+
mockRecoveryPhoneManager.getUnconfirmed.mockResolvedValueOnce({
927+
isSetup: true,
928+
phoneNumber,
929+
});
930+
const result = await service.replacePhoneNumber(uid, code);
931+
932+
expect(result).toBe(true);
933+
});
934+
935+
it('returns false if there is no existing unconfirmed code', async () => {
936+
mockRecoveryPhoneManager.replacePhoneNumber.mockResolvedValue(true);
937+
mockRecoveryPhoneManager.getUnconfirmed.mockResolvedValueOnce({});
938+
939+
const result = await service.replacePhoneNumber(uid, code);
940+
941+
expect(result).toBe(false);
942+
});
943+
944+
it('returns false if existing unconfirmed code is not for setup', async () => {
945+
mockRecoveryPhoneManager.replacePhoneNumber.mockResolvedValue(true);
946+
mockRecoveryPhoneManager.getUnconfirmed.mockResolvedValueOnce({
947+
isSetup: false,
948+
phoneNumber,
949+
});
950+
const result = await service.replacePhoneNumber(uid, code);
951+
952+
expect(result).toBe(false);
953+
});
954+
});
889955
});

0 commit comments

Comments
 (0)