Skip to content

Commit ec4717a

Browse files
committed
fix(tests): Update sms functional tests to run in stage/prod
1 parent 5662041 commit ec4717a

4 files changed

Lines changed: 201 additions & 31 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,7 @@
211211
"@types/react-test-renderer": "^18",
212212
"@types/set-value": "^4",
213213
"@types/superagent": "4.1.11",
214+
"@types/twilio": "^3.19.3",
214215
"@types/uuid": "^10.0.0",
215216
"@typescript-eslint/eslint-plugin": "^5.59.1",
216217
"@typescript-eslint/parser": "^7.1.1",

packages/functional-tests/lib/sms.ts

Lines changed: 100 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,34 +2,102 @@
22
* License, v. 2.0. If a copy of the MPL was not distributed with this
33
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
44

5+
import TwilioSDK from 'twilio';
56
import Redis from 'ioredis';
67
import type { Redis as RedisType } from 'ioredis';
78

89
function wait() {
910
return new Promise((r) => setTimeout(r, 500));
1011
}
1112

13+
const accountSid = process.env.RECOVERY_PHONE__TWILIO__ACCOUNT_SID;
14+
const authToken = process.env.RECOVERY_PHONE__TWILIO__AUTH_TOKEN;
15+
const testPhoneNumber = process.env.RECOVERY_PHONE__TWILIO__TEST_NUMBER;
16+
1217
export class SmsClient {
13-
private client: RedisType;
18+
private twilioClient?: TwilioSDK.Twilio;
19+
private redisClient?: RedisType;
1420
private uidCodes: Map<string, string>;
15-
private isConnected = false;
16-
private hasLoggedConnectionError = false;
21+
private lastCode: string | undefined;
22+
private redisClientConnected = false;
23+
private hasLoggedRedisConnectionError = false;
1724

1825
constructor() {
19-
this.client = new Redis();
26+
if (accountSid && authToken && testPhoneNumber) {
27+
this.twilioClient = new TwilioSDK.Twilio(accountSid, authToken);
28+
} else {
29+
this.redisClient = new Redis();
30+
this.redisClient.on('ready', () => {
31+
this.redisClientConnected = true;
32+
this.hasLoggedRedisConnectionError = false;
33+
});
34+
35+
this.redisClient.on('error', (err: Error) => {
36+
if (!this.hasLoggedRedisConnectionError) {
37+
this.hasLoggedRedisConnectionError = true;
38+
}
39+
this.redisClientConnected = false;
40+
});
41+
}
2042
this.uidCodes = new Map();
43+
}
2144

22-
this.client.on('ready', () => {
23-
this.isConnected = true;
24-
this.hasLoggedConnectionError = false;
25-
});
45+
isTwilioEnabled() {
46+
return !!this.twilioClient;
47+
}
2648

27-
this.client.on('error', (err: Error) => {
28-
if (!this.hasLoggedConnectionError) {
29-
this.hasLoggedConnectionError = true;
49+
async getCode(recipientNumber: string, uid: string, timeout = 10000) {
50+
if (this.isTwilioEnabled()) {
51+
return this._getCodeTwilio(recipientNumber);
52+
} else {
53+
return this._getCodeLocal(uid, timeout);
54+
}
55+
}
56+
57+
async _getCodeTwilio(
58+
recipientNumber: string,
59+
limit = 1,
60+
codeRegex = /\b\d{6}\b/,
61+
timeout = 10000,
62+
startTime = Date.now() - 1000
63+
): Promise<string> {
64+
if (!this.twilioClient) {
65+
throw new Error('Twilio API not enabled');
66+
}
67+
68+
const expires = Date.now() + timeout;
69+
70+
while (Date.now() < expires) {
71+
const messages = await this.twilioClient.messages.list({
72+
to: recipientNumber,
73+
dateSentAfter: new Date(startTime),
74+
limit,
75+
});
76+
77+
if (!messages.length) {
78+
await wait();
79+
continue;
3080
}
31-
this.isConnected = false;
32-
});
81+
82+
const lastMessage = messages[0];
83+
const match = lastMessage.body.match(codeRegex);
84+
85+
if (!match) {
86+
await wait();
87+
continue;
88+
}
89+
90+
const code = match[0];
91+
if (code === this.lastCode) {
92+
await wait();
93+
continue;
94+
}
95+
96+
this.lastCode = code;
97+
return code;
98+
}
99+
100+
throw new Error('Timeout: No new code found within the specified time');
33101
}
34102

35103
/**
@@ -38,8 +106,11 @@ export class SmsClient {
38106
* @param uid
39107
* @param timeout
40108
*/
41-
async getCode(uid: string, timeout = 10000): Promise<string> {
42-
if (!this.isConnected) {
109+
async _getCodeLocal(uid: string, timeout = 10000): Promise<string> {
110+
if (!this.redisClient) {
111+
throw new Error('Not connected to Redis');
112+
}
113+
if (!this.redisClientConnected) {
43114
throw new Error('Not connected to Redis');
44115
}
45116

@@ -52,19 +123,29 @@ export class SmsClient {
52123
let newestCreatedAt = -1;
53124

54125
do {
55-
const [newCursor, keys] = await this.client.scan(
126+
const [newCursor, keys] = await this.redisClient.scan(
56127
cursor,
57128
'MATCH',
58129
redisKeyPattern
59130
);
60131
cursor = newCursor;
61132

62133
for (const key of keys) {
63-
const valueRaw = await this.client.get(key);
64-
if (!valueRaw) {
134+
const valueRaw = await this.redisClient.get(key);
135+
136+
if (valueRaw === null) continue;
137+
let value;
138+
try {
139+
value = JSON.parse(valueRaw);
140+
} catch (err) {
65141
continue;
66142
}
67-
const value = JSON.parse(valueRaw);
143+
if (
144+
typeof value !== 'object' ||
145+
value === null ||
146+
typeof value.createdAt !== 'number'
147+
)
148+
continue;
68149

69150
if (!newestKey || value.createdAt > newestCreatedAt) {
70151
newestKey = key;

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

Lines changed: 64 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,45 @@ import { FirefoxCommand } from '../../lib/channels';
1616
import { syncDesktopOAuthQueryParams } from '../../lib/query-params';
1717
import { getCode } from 'fxa-settings/src/lib/totp';
1818

19+
const realTestPhoneNumber = process.env.RECOVERY_PHONE__TWILIO__TEST_NUMBER;
20+
21+
function getPhoneNumber(env: string) {
22+
if (env !== 'local' && realTestPhoneNumber) {
23+
return realTestPhoneNumber;
24+
}
25+
// See Twilio test credentials phone numbers: https://www.twilio.com/docs/iam/test-credentials
26+
return '4159929960';
27+
}
28+
1929
test.describe('severity-1 #smoke', () => {
2030
test.describe('recovery phone', () => {
21-
test.beforeEach(async ({ pages: { configPage } }, { project }) => {
31+
// Run these tests sequentially when using the Twilio API because they rely on the same test phone number.
32+
// When using the Twilio API, we cannot determine the order in which the messages were received.
33+
if (realTestPhoneNumber) {
34+
test.describe.configure({ mode: 'serial' });
35+
} else {
36+
test.describe.configure({ mode: 'parallel' });
37+
}
38+
39+
test.beforeEach(async ({ pages: { configPage }, target }) => {
2240
// Ensure that the feature flag is enabled
2341
const config = await configPage.getConfig();
24-
test.fixme(project.name !== 'local', 'FXA-11159');
2542
test.skip(config.featureFlags.enableAdding2FABackupPhone !== true);
2643
test.skip(config.featureFlags.enableUsing2FABackupPhone !== true);
44+
45+
// Twilio does not allow you to fetch messages when using test credentials.
46+
// Therefore, we fallback to peeking at Redis to get confirmation codes.
47+
if (target.name === 'local') {
48+
expect(
49+
target.smsClient.isTwilioEnabled(),
50+
'Local env found, use redis and Twilio test creds'
51+
).toBeFalsy();
52+
} else {
53+
expect(
54+
target.smsClient.isTwilioEnabled(),
55+
'Stage/Prod env, use Twilio API'
56+
).toBeTruthy();
57+
}
2758
});
2859

2960
test('setup fails with invalid number', async ({
@@ -68,12 +99,15 @@ test.describe('severity-1 #smoke', () => {
6899

69100
await expect(recoveryPhone.addHeader()).toBeVisible();
70101

71-
await recoveryPhone.enterPhoneNumber('4159929960');
102+
await recoveryPhone.enterPhoneNumber(getPhoneNumber(target.name));
72103
await recoveryPhone.clickSendCode();
73104

74105
await expect(recoveryPhone.confirmHeader).toBeVisible();
75106

76-
const code = await target.smsClient.getCode(credentials.uid);
107+
const code = await target.smsClient.getCode(
108+
getPhoneNumber(target.name),
109+
credentials.uid
110+
);
77111

78112
// Invalid code
79113
await recoveryPhone.enterCode('123456');
@@ -84,7 +118,10 @@ test.describe('severity-1 #smoke', () => {
84118

85119
// Sends a new code
86120
await recoveryPhone.clickResendCode();
87-
const nextCode = await target.smsClient.getCode(credentials.uid);
121+
const nextCode = await target.smsClient.getCode(
122+
getPhoneNumber(target.name),
123+
credentials.uid
124+
);
88125

89126
expect(code).not.toEqual(nextCode);
90127

@@ -332,13 +369,19 @@ test.describe('severity-1 #smoke', () => {
332369
page.getByText('Invalid or expired confirmation code')
333370
).toBeVisible();
334371

335-
const originalCode = await target.smsClient.getCode(credentials.uid);
372+
const originalCode = await target.smsClient.getCode(
373+
getPhoneNumber(target.name),
374+
credentials.uid
375+
);
336376

337377
// Sends a new code
338378
await signinRecoveryPhone.clickResendCode();
339379
await expect(page.getByText('Code sent')).toBeVisible();
340380

341-
const nextCode = await target.smsClient.getCode(credentials.uid);
381+
const nextCode = await target.smsClient.getCode(
382+
getPhoneNumber(target.name),
383+
credentials.uid
384+
);
342385

343386
expect(originalCode).not.toEqual(nextCode);
344387

@@ -590,14 +633,17 @@ async function setupRecoveryPhone({
590633

591634
await expect(recoveryPhone.addHeader()).toBeVisible();
592635

593-
await recoveryPhone.enterPhoneNumber('4159929960');
636+
await recoveryPhone.enterPhoneNumber(getPhoneNumber(target.name));
594637
await recoveryPhone.clickSendCode();
595638

596639
await expect(recoveryPhone.confirmHeader).toBeVisible();
597640

598-
const registerCode = await target.smsClient.getCode(credentials.uid);
641+
const code = await target.smsClient.getCode(
642+
getPhoneNumber(target.name),
643+
credentials.uid
644+
);
599645

600-
await recoveryPhone.enterCode(registerCode);
646+
await recoveryPhone.enterCode(code);
601647
await recoveryPhone.clickConfirm();
602648

603649
await page.waitForURL(/settings/);
@@ -645,13 +691,19 @@ async function fillOutRecoveryPhoneFromEmailFirst({
645691
page.getByText('Invalid or expired confirmation code')
646692
).toBeVisible();
647693

648-
const originalCode = await target.smsClient.getCode(credentials.uid);
694+
const originalCode = await target.smsClient.getCode(
695+
getPhoneNumber(target.name),
696+
credentials.uid
697+
);
649698

650699
// Sends a new code
651700
await signinRecoveryPhone.clickResendCode();
652701
await expect(page.getByText('Code sent')).toBeVisible();
653702

654-
const nextCode = await target.smsClient.getCode(credentials.uid);
703+
const nextCode = await target.smsClient.getCode(
704+
getPhoneNumber(target.name),
705+
credentials.uid
706+
);
655707

656708
expect(originalCode).not.toEqual(nextCode);
657709

yarn.lock

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26436,6 +26436,15 @@ __metadata:
2643626436
languageName: node
2643726437
linkType: hard
2643826438

26439+
"@types/twilio@npm:^3.19.3":
26440+
version: 3.19.3
26441+
resolution: "@types/twilio@npm:3.19.3"
26442+
dependencies:
26443+
twilio: "*"
26444+
checksum: f9fcd6485a003bef55baa91b99e1af62645a653da3b8a2d7cc2c390eb6b09fb6e1f3d72cae78e9a88d23baa13c5e02ccf27070da59fd709db6f1e401da5c14f3
26445+
languageName: node
26446+
linkType: hard
26447+
2643926448
"@types/ua-parser-js@npm:^0.7.36":
2644026449
version: 0.7.39
2644126450
resolution: "@types/ua-parser-js@npm:0.7.39"
@@ -29684,6 +29693,17 @@ __metadata:
2968429693
languageName: node
2968529694
linkType: hard
2968629695

29696+
"axios@npm:^1.7.8":
29697+
version: 1.7.9
29698+
resolution: "axios@npm:1.7.9"
29699+
dependencies:
29700+
follow-redirects: ^1.15.6
29701+
form-data: ^4.0.0
29702+
proxy-from-env: ^1.1.0
29703+
checksum: cb8ce291818effda09240cb60f114d5625909b345e10f389a945320e06acf0bc949d0f8422d25720f5dd421362abee302c99f5e97edec4c156c8939814b23d19
29704+
languageName: node
29705+
linkType: hard
29706+
2968729707
"axios@npm:~0.21.1":
2968829708
version: 0.21.4
2968929709
resolution: "axios@npm:0.21.4"
@@ -42738,6 +42758,7 @@ fsevents@~2.1.1:
4273842758
"@types/react-test-renderer": ^18
4273942759
"@types/set-value": ^4
4274042760
"@types/superagent": 4.1.11
42761+
"@types/twilio": ^3.19.3
4274142762
"@types/uuid": ^10.0.0
4274242763
"@typescript-eslint/eslint-plugin": ^5.59.1
4274342764
"@typescript-eslint/parser": ^7.1.1
@@ -68607,6 +68628,21 @@ [email protected]:
6860768628
languageName: node
6860868629
linkType: hard
6860968630

68631+
"twilio@npm:*":
68632+
version: 5.4.5
68633+
resolution: "twilio@npm:5.4.5"
68634+
dependencies:
68635+
axios: ^1.7.8
68636+
dayjs: ^1.11.9
68637+
https-proxy-agent: ^5.0.0
68638+
jsonwebtoken: ^9.0.2
68639+
qs: ^6.9.4
68640+
scmp: ^2.1.0
68641+
xmlbuilder: ^13.0.2
68642+
checksum: 11074d4789adfb94518ebce8a1162ed308925af44bb54cb3f86aa2f85d72cad6b7c55d248278ccc1f67e1040751ac0e718b736df0c3f6925b4b9432bdbd97506
68643+
languageName: node
68644+
linkType: hard
68645+
6861068646
"twilio@npm:^5.3.5":
6861168647
version: 5.3.5
6861268648
resolution: "twilio@npm:5.3.5"

0 commit comments

Comments
 (0)