Skip to content

Commit e587081

Browse files
authored
Merge pull request #18533 from mozilla/FXA-11111
task(recovery-phone): Standardize SMS code validity on resend
2 parents acb82e8 + 09fafa6 commit e587081

12 files changed

+152
-79
lines changed

libs/accounts/recovery-phone/README.md

+2
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ Run `nx test-unit accounts-recovery-phone` to execute the unit tests via [Jest](
1212

1313
## Running integration tests
1414

15+
Make sure local infra (ie databases) are spun up by checking status. `yarn pm2 status` should show redis and mysql instances running. If not, run `yarn start infrastructure`.
16+
1517
Run `nx test-integration accounts-recovery-phone` to execute the integration tests via [Jest](https://jestjs.io).
1618

1719
## Testing webhook callbacks for message status updates

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

+57-28
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
PhoneNumberLookupData,
3+
RECOVERY_PHONE_REDIS_PREFIX,
34
RecoveryPhoneManager,
45
} from './recovery-phone.manager';
56
import {
@@ -9,10 +10,12 @@ import {
910
RecoveryPhoneFactory,
1011
} from '@fxa/shared/db/mysql/account';
1112
import { Test } from '@nestjs/testing';
13+
import Redis from 'ioredis';
1214

1315
describe('RecoveryPhoneManager', () => {
1416
let recoveryPhoneManager: RecoveryPhoneManager;
1517
let db: AccountDatabase;
18+
let redis: Redis.Redis;
1619
const dateMock = jest.spyOn(global.Date, 'now');
1720

1821
// Taken from: https://www.twilio.com/docs/lookup/v2-api#code-lookup-with-data-packages
@@ -38,19 +41,29 @@ describe('RecoveryPhoneManager', () => {
3841
callForwarding: {},
3942
};
4043

41-
const mockRedis = {
42-
set: jest.fn(),
43-
get: jest.fn(),
44-
del: jest.fn(),
45-
};
44+
async function clearRedisSmsKeys() {
45+
const addedKeys = await redis.keys(RECOVERY_PHONE_REDIS_PREFIX);
46+
for (const key of addedKeys) {
47+
await redis.del(key);
48+
}
49+
}
4650

4751
beforeAll(async () => {
4852
dateMock.mockImplementation(() => 1739227529776);
53+
4954
db = await testAccountDatabaseSetup([
5055
'accounts',
5156
'recoveryPhones',
5257
'recoveryCodes',
5358
]);
59+
60+
// Since this is an integration we can expect that
61+
// infrastructure is running.
62+
redis = new Redis('localhost');
63+
64+
// Make sure sms key state is clean
65+
await clearRedisSmsKeys();
66+
5467
const moduleRef = await Test.createTestingModule({
5568
providers: [
5669
RecoveryPhoneManager,
@@ -60,7 +73,7 @@ describe('RecoveryPhoneManager', () => {
6073
},
6174
{
6275
provide: 'RecoveryPhoneRedis',
63-
useValue: mockRedis,
76+
useValue: redis,
6477
},
6578
],
6679
}).compile();
@@ -69,6 +82,7 @@ describe('RecoveryPhoneManager', () => {
6982
});
7083

7184
afterAll(async () => {
85+
await clearRedisSmsKeys();
7286
await db.destroy();
7387
dateMock.mockReset();
7488
});
@@ -192,48 +206,63 @@ describe('RecoveryPhoneManager', () => {
192206
insertIntoSpy.mockRestore();
193207
});
194208

195-
it('should store unconfirmed phone number data in Redis', async () => {
209+
it('should store and retrieve unconfirmed phone number data in Redis', async () => {
196210
const mockPhone = RecoveryPhoneFactory();
197211
const { uid, phoneNumber } = mockPhone;
198-
const code = '123456';
199212
const isSetup = true;
200213
await recoveryPhoneManager.storeUnconfirmed(
201214
uid.toString('hex'),
202-
code,
215+
'111111',
203216
phoneNumber,
204217
isSetup,
205218
mockLookUpData
206219
);
207-
208-
const redisKey = `sms-attempt:${uid.toString('hex')}:${code}`;
209-
210-
expect(mockRedis.set).toHaveBeenCalledWith(
211-
redisKey,
212-
expect.any(String),
213-
'EX',
214-
600
220+
await recoveryPhoneManager.storeUnconfirmed(
221+
uid.toString('hex'),
222+
'222222',
223+
phoneNumber,
224+
isSetup
215225
);
216226

217-
const expectedData = expect.objectContaining({
218-
createdAt: expect.any(Number),
227+
// Store one item for different accounts to make sure lookup is filtering
228+
// on uid
229+
await recoveryPhoneManager.storeUnconfirmed(
230+
uid.toString('hex').replace(/./g, '1'),
231+
'333333',
219232
phoneNumber,
220-
isSetup,
221-
lookupData: JSON.stringify(mockLookUpData),
222-
});
233+
isSetup
234+
);
235+
236+
const firstUnconfirmedCode = await recoveryPhoneManager.getUnconfirmed(
237+
uid.toString('hex'),
238+
'111111'
239+
);
240+
expect(firstUnconfirmedCode).toBeDefined();
241+
expect(firstUnconfirmedCode?.lookupData).toBeDefined();
242+
expect(firstUnconfirmedCode?.lookupData).toEqual(
243+
JSON.stringify(mockLookUpData)
244+
);
223245

224-
const storedData = mockRedis.set.mock.calls[0][1];
225-
expect(() => JSON.parse(storedData)).not.toThrow();
226-
const parsedData = JSON.parse(mockRedis.set.mock.calls[0][1]);
227-
expect(parsedData).toEqual(expectedData);
246+
const secondUnconfirmedCode = await recoveryPhoneManager.getUnconfirmed(
247+
uid.toString('hex'),
248+
'222222'
249+
);
250+
expect(secondUnconfirmedCode).toBeDefined();
251+
expect(secondUnconfirmedCode?.lookupData).toEqual(null);
252+
253+
const allUnconfirmedCodes =
254+
await recoveryPhoneManager.getAllUnconfirmedCodes(uid.toString('hex'));
255+
expect(allUnconfirmedCodes.length).toEqual(2);
256+
expect(allUnconfirmedCodes).toContain('111111');
257+
expect(allUnconfirmedCodes).toContain('222222');
258+
expect(allUnconfirmedCodes).not.toContain('333333');
228259
});
229260

230261
it('should return null if no unconfirmed phone number data is found in Redis', async () => {
231262
const mockPhone = RecoveryPhoneFactory();
232263
const { uid } = mockPhone;
233264
const code = '123456';
234265

235-
mockRedis.get.mockResolvedValue(null);
236-
237266
const result = await recoveryPhoneManager.getUnconfirmed(
238267
uid.toString('hex'),
239268
code

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

+30-8
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,13 @@ export type PhoneNumberLookupData = ReturnType<
3131
typeof PhoneNumberInstance.prototype.toJSON
3232
>;
3333

34+
/**
35+
* Standard prefix for all recovery phone entries in redis.
36+
*/
37+
export const RECOVERY_PHONE_REDIS_PREFIX = 'recovery-phone:sms-attempt';
38+
3439
@Injectable()
3540
export class RecoveryPhoneManager {
36-
private readonly redisPrefix = 'sms-attempt';
37-
3841
constructor(
3942
@Inject(AccountDbProvider) private readonly db: AccountDatabase,
4043
@Inject('RecoveryPhoneRedis') private readonly redisClient: Redis
@@ -128,7 +131,7 @@ export class RecoveryPhoneManager {
128131
isSetup: boolean,
129132
lookupData?: PhoneNumberLookupData
130133
): Promise<void> {
131-
const redisKey = `${this.redisPrefix}:${uid}:${code}`;
134+
const redisKey = `${RECOVERY_PHONE_REDIS_PREFIX}:${uid}:${code}`;
132135
const data = {
133136
createdAt: Date.now(),
134137
phoneNumber,
@@ -159,7 +162,7 @@ export class RecoveryPhoneManager {
159162
isSetup: boolean;
160163
lookupData: Record<string, any> | null;
161164
} | null> {
162-
const redisKey = `${this.redisPrefix}:${uid}:${code}`;
165+
const redisKey = `${RECOVERY_PHONE_REDIS_PREFIX}:${uid}:${code}`;
163166
const data = await this.redisClient.get(redisKey);
164167

165168
if (!data) {
@@ -174,9 +177,28 @@ export class RecoveryPhoneManager {
174177
*
175178
* @param uid
176179
*/
177-
async getAllUnconfirmed(uid: string): Promise<string[]> {
178-
const redisKey = `${this.redisPrefix}:${uid}:*`;
179-
return await this.redisClient.keys(redisKey);
180+
private async getAllUnconfirmedKeys(uid: string): Promise<string[]> {
181+
const pattern = `${RECOVERY_PHONE_REDIS_PREFIX}:${uid}:*`;
182+
let cursor = '0';
183+
let keys: string[] = [];
184+
185+
do {
186+
const reply = await this.redisClient.scan(cursor, 'MATCH', pattern);
187+
cursor = reply[0];
188+
keys = keys.concat(reply[1]);
189+
} while (cursor !== '0');
190+
191+
return keys;
192+
}
193+
194+
/**
195+
* Returns codes sent out for all unconfirmed phone numbers for a user.
196+
* @param uid
197+
* @returns
198+
*/
199+
async getAllUnconfirmedCodes(uid: string): Promise<string[]> {
200+
const keys = await this.getAllUnconfirmedKeys(uid);
201+
return keys.map((x) => x.split(':').pop() || '').filter((x) => !!x);
180202
}
181203

182204
/**
@@ -187,7 +209,7 @@ export class RecoveryPhoneManager {
187209
* @returns
188210
*/
189211
async removeCode(uid: string, code: string) {
190-
const redisKey = `${this.redisPrefix}:${uid}:${code}`;
212+
const redisKey = `${RECOVERY_PHONE_REDIS_PREFIX}:${uid}:${code}`;
191213
const count = await this.redisClient.del(redisKey);
192214
return count > 0;
193215
}

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export const RecoveryPhoneRedisProvider = {
2020
provide: 'RecoveryPhoneRedis',
2121
useFactory: (config: ConfigService) => {
2222
const baseRedisConfig = config.get('redis');
23-
const recoveryPhoneRedisConfig = config.get('recoveryPhone.redis');
23+
const recoveryPhoneRedisConfig = config.get('redis.recoveryPhone');
2424
return new Redis({
2525
...baseRedisConfig,
2626
...recoveryPhoneRedisConfig,

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

+14-12
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ describe('RecoveryPhoneService', () => {
5353
const mockRecoveryPhoneManager = {
5454
storeUnconfirmed: jest.fn(),
5555
getUnconfirmed: jest.fn(),
56-
getAllUnconfirmed: jest.fn(),
56+
getAllUnconfirmedCodes: jest.fn(),
5757
registerPhoneNumber: jest.fn(),
5858
removePhoneNumber: jest.fn(),
5959
getConfirmedPhoneNumber: jest.fn(),
@@ -118,7 +118,7 @@ describe('RecoveryPhoneService', () => {
118118
};
119119
});
120120
mockRecoveryPhoneManager.hasRecoveryCodes.mockResolvedValue(true);
121-
mockRecoveryPhoneManager.getAllUnconfirmed.mockResolvedValue([]);
121+
mockRecoveryPhoneManager.getAllUnconfirmedCodes.mockResolvedValue([]);
122122

123123
const module: TestingModule = await Test.createTestingModule({
124124
providers: [
@@ -178,15 +178,15 @@ describe('RecoveryPhoneService', () => {
178178
phoneNumber,
179179
true
180180
);
181-
expect(mockRecoveryPhoneManager.getAllUnconfirmed).toBeCalledWith(uid);
181+
expect(mockRecoveryPhoneManager.getAllUnconfirmedCodes).toBeCalledWith(uid);
182182
expect(result).toBeTruthy();
183183
});
184184

185185
it('Should send new code to set up a phone number', async () => {
186186
mockOtpManager.generateCode.mockReturnValue(code);
187-
mockRecoveryPhoneManager.getAllUnconfirmed.mockResolvedValue([
188-
'this:is:the:code123',
189-
'this:is:the:code456',
187+
mockRecoveryPhoneManager.getAllUnconfirmedCodes.mockResolvedValue([
188+
'code123',
189+
'code456',
190190
]);
191191

192192
const result = await service.setupPhoneNumber(
@@ -210,7 +210,7 @@ describe('RecoveryPhoneService', () => {
210210
phoneNumber,
211211
true
212212
);
213-
expect(mockRecoveryPhoneManager.getAllUnconfirmed).toBeCalledWith(uid);
213+
expect(mockRecoveryPhoneManager.getAllUnconfirmedCodes).toBeCalledWith(uid);
214214
});
215215

216216
it('handles message template when provided to set up phone number', async () => {
@@ -235,7 +235,7 @@ describe('RecoveryPhoneService', () => {
235235
phoneNumber,
236236
true
237237
);
238-
expect(mockRecoveryPhoneManager.getAllUnconfirmed).toBeCalledWith(uid);
238+
expect(mockRecoveryPhoneManager.getAllUnconfirmedCodes).toBeCalledWith(uid);
239239
});
240240

241241
it('Will reject a phone number that is not part of launch', async () => {
@@ -601,9 +601,9 @@ describe('RecoveryPhoneService', () => {
601601

602602
it('Should send new code for setup phone number', async () => {
603603
mockOtpManager.generateCode.mockReturnValue(code);
604-
mockRecoveryPhoneManager.getAllUnconfirmed.mockResolvedValue([
605-
'this:is:the:code123',
606-
'this:is:the:code456',
604+
mockRecoveryPhoneManager.getAllUnconfirmedCodes.mockResolvedValue([
605+
'code123',
606+
'code456',
607607
]);
608608

609609
mockRecoveryPhoneManager.getConfirmedPhoneNumber.mockResolvedValueOnce({
@@ -639,7 +639,9 @@ describe('RecoveryPhoneService', () => {
639639
uid,
640640
'code456'
641641
);
642-
expect(mockRecoveryPhoneManager.getAllUnconfirmed).toBeCalledWith(uid);
642+
expect(mockRecoveryPhoneManager.getAllUnconfirmedCodes).toBeCalledWith(
643+
uid
644+
);
643645
});
644646
});
645647

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

+8-16
Original file line numberDiff line numberDiff line change
@@ -134,14 +134,10 @@ export class RecoveryPhoneService {
134134
}
135135

136136
// Invalidate and remove any or all previous unconfirmed code entries
137-
const unconfirmedKeys = await this.recoveryPhoneManager.getAllUnconfirmed(
138-
uid
139-
);
140-
for (const key of unconfirmedKeys) {
141-
const code = key.split(':').pop();
142-
if (code) {
143-
await this.recoveryPhoneManager.removeCode(uid, code);
144-
}
137+
const unconfirmedCodes =
138+
await this.recoveryPhoneManager.getAllUnconfirmedCodes(uid);
139+
for (const code of unconfirmedCodes) {
140+
await this.recoveryPhoneManager.removeCode(uid, code);
145141
}
146142

147143
// Rejects the phone number if it has been registered for too many accounts
@@ -456,14 +452,10 @@ export class RecoveryPhoneService {
456452
}
457453

458454
// Invalidate and remove any or all previous unconfirmed code entries
459-
const unconfirmedKeys = await this.recoveryPhoneManager.getAllUnconfirmed(
460-
uid
461-
);
462-
for (const key of unconfirmedKeys) {
463-
const oldCode = key.split(':').pop();
464-
if (oldCode) {
465-
await this.recoveryPhoneManager.removeCode(uid, oldCode);
466-
}
455+
const unconfirmedCodes =
456+
await this.recoveryPhoneManager.getAllUnconfirmedCodes(uid);
457+
for (const oldCode of unconfirmedCodes) {
458+
await this.recoveryPhoneManager.removeCode(uid, oldCode);
467459
}
468460

469461
// Generate a new otp code, and store it as unconfirmed for later validation

libs/accounts/recovery-phone/src/lib/twilio.provider.ts

+15-2
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,22 @@ export const TwilioFactory: Provider<Twilio> = {
4646
if (credentialMode === 'apiKeys' && accountSid && apiKey && apiSecret) {
4747
return new Twilio(apiKey, apiSecret, { accountSid });
4848
}
49-
5049
throw new Error(
51-
'Invalid configuration state. Check docs for TwilioConfig, a value is probably missing.'
50+
`Invalid configuration state for credential mode ${credentialMode}.\n ${JSON.stringify(
51+
{
52+
credentialMode,
53+
has: {
54+
testAccountSid: !!testAccountSid,
55+
testAuthToken: !!testAuthToken,
56+
accountSid: !!accountSid,
57+
authToken: !!authToken,
58+
apiKey: !!apiKey,
59+
apiSecret: !!apiKey,
60+
},
61+
},
62+
null,
63+
''
64+
)}`
5265
);
5366
},
5467
inject: [TwilioConfig],

0 commit comments

Comments
 (0)