Skip to content

Commit 73da291

Browse files
authored
Merge pull request #18287 from mozilla/fxa-10378
feat(tests): Add functional tests for sms flows
2 parents 98e8439 + 602b860 commit 73da291

13 files changed

Lines changed: 962 additions & 0 deletions

File tree

.circleci/config.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,11 @@ executors:
165165
REACT_CONVERSION_PAIR_ROUTES: true
166166
REACT_CONVERSION_POST_VERIFY_OTHER_ROUTES: true
167167
REACT_CONVERSION_POST_VERIFY_CAD_VIA_QR_ROUTES: true
168+
# Recovery phone feature flags
169+
FEATURE_FLAGS_ADDING_2FA_BACKUP_PHONE: true
170+
FEATURE_FLAGS_USING_2FA_BACKUP_PHONE: true
171+
GEODB_LOCATION_OVERRIDE: '{"location": {"countryCode": "US", "postalCode": "85001"}}'
172+
RECOVERY_PHONE__ENABLED: true
168173
CUSTOMS_SERVER_URL: none
169174
HUSKY_SKIP_INSTALL: 1
170175
AUTH_CLOUDTASKS_USE_LOCAL_EMULATOR: true

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ export class RecoveryPhoneManager {
127127
): Promise<void> {
128128
const redisKey = `${this.redisPrefix}:${uid}:${code}`;
129129
const data = {
130+
createdAt: Date.now(),
130131
phoneNumber,
131132
isSetup,
132133
lookupData: lookupData ? JSON.stringify(lookupData) : null,
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import Redis from 'ioredis';
6+
import type { Redis as RedisType } from 'ioredis';
7+
8+
function wait() {
9+
return new Promise((r) => setTimeout(r, 500));
10+
}
11+
12+
export class SmsClient {
13+
private client: RedisType;
14+
private uidCodes: Map<string, string>;
15+
16+
constructor() {
17+
this.client = new Redis();
18+
this.uidCodes = new Map();
19+
}
20+
21+
/**
22+
* Get the code stored in redis that was sent to the user via SMS.
23+
*
24+
* @param uid
25+
* @param timeout
26+
*/
27+
async getCode(uid: string, timeout = 10000): Promise<string> {
28+
const redisKeyPattern = `recovery-phone:sms-attempt:${uid}:*`;
29+
const expires = Date.now() + timeout;
30+
31+
while (Date.now() < expires) {
32+
let cursor = '0';
33+
let newestKey: string | null = null;
34+
let newestCreatedAt = -1;
35+
36+
do {
37+
const [newCursor, keys] = await this.client.scan(
38+
cursor,
39+
'MATCH',
40+
redisKeyPattern
41+
);
42+
cursor = newCursor;
43+
44+
for (const key of keys) {
45+
const valueRaw = await this.client.get(key);
46+
if (!valueRaw) {
47+
continue;
48+
}
49+
const value = JSON.parse(valueRaw);
50+
51+
if (!newestKey || value.createdAt > newestCreatedAt) {
52+
newestKey = key;
53+
newestCreatedAt = value.createdAt;
54+
}
55+
}
56+
} while (cursor !== '0');
57+
58+
// If no keys are found, wait and try again.
59+
if (!newestKey) {
60+
await wait();
61+
continue;
62+
}
63+
64+
const code = newestKey.split(':')[3];
65+
const lastCode = this.uidCodes.get(uid);
66+
67+
// If the code is the same as the last one, wait and try again.
68+
if (lastCode === code) {
69+
await wait();
70+
continue;
71+
}
72+
73+
this.uidCodes.set(uid, code);
74+
return code;
75+
}
76+
77+
throw new Error('KeyTimeout');
78+
}
79+
}

packages/functional-tests/lib/targets/base.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import { SaltVersion } from '../../../fxa-auth-client/lib/salt';
66
import AuthClient from '../../../fxa-auth-client/lib/client';
77
import { EmailClient } from '../email';
8+
import { SmsClient } from '../sms';
89
import { TargetName } from './index';
910

1011
export type Credentials = Awaited<ReturnType<AuthClient['signUp']>> & {
@@ -23,6 +24,7 @@ interface SubConfig {
2324
export abstract class BaseTarget {
2425
readonly authClient: AuthClient;
2526
readonly emailClient: EmailClient;
27+
readonly smsClient: SmsClient;
2628
abstract readonly contentServerUrl: string;
2729
abstract readonly paymentsServerUrl: string;
2830
abstract readonly relierUrl: string;
@@ -36,6 +38,7 @@ export abstract class BaseTarget {
3638
);
3739
this.authClient = this.createAuthClient(keyStretchVersion);
3840
this.emailClient = new EmailClient(emailUrl);
41+
this.smsClient = new SmsClient();
3942
}
4043

4144
get baseUrl() {

packages/functional-tests/pages/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,14 @@ import { LoginPage } from './login';
2020
import { PostVerifyPage } from './postVerify';
2121
import { PrivacyPage } from './privacy';
2222
import { RecoveryKeyPage } from './settings/recoveryKey';
23+
import { RecoveryPhoneSetupPage } from './settings/recoveryPhone';
2324
import { RelierPage } from './relier';
2425
import { ResetPasswordPage } from './resetPassword';
2526
import { SecondaryEmailPage } from './settings/secondaryEmail';
2627
import { SettingsPage } from './settings';
2728
import { SigninPage } from './signin';
29+
import { SigninRecoveryChoicePage } from './signinRecoveryChoice';
30+
import { SigninRecoveryPhonePage } from './signinRecoveryPhone';
2831
import { SigninRecoveryCodePage } from './signinRecoveryCode';
2932
import { SigninTokenCodePage } from './signinTokenCode';
3033
import { SigninTotpCodePage } from './signinTotpCode';
@@ -55,11 +58,14 @@ export function create(page: Page, target: BaseTarget) {
5558
postVerify: new PostVerifyPage(page, target),
5659
privacy: new PrivacyPage(page, target),
5760
recoveryKey: new RecoveryKeyPage(page, target),
61+
recoveryPhone: new RecoveryPhoneSetupPage(page, target),
5862
relier: new RelierPage(page, target),
5963
resetPassword: new ResetPasswordPage(page, target),
6064
secondaryEmail: new SecondaryEmailPage(page, target),
6165
settings: new SettingsPage(page, target),
6266
signin: new SigninPage(page, target),
67+
signinRecoveryChoice: new SigninRecoveryChoicePage(page, target),
68+
signinRecoveryPhone: new SigninRecoveryPhonePage(page, target),
6369
signinRecoveryCode: new SigninRecoveryCodePage(page, target),
6470
signinTokenCode: new SigninTokenCodePage(page, target),
6571
signinTotpCode: new SigninTotpCodePage(page, target),

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,14 @@ export class TotpRow extends UnitRow {
9696
get changeButton() {
9797
return this.page.getByRole('button', { name: 'Get new codes' });
9898
}
99+
100+
get addRecoveryPhoneButton() {
101+
return this.page.getByRole('button', { name: 'Add' });
102+
}
103+
104+
get removeRecoveryPhoneButton() {
105+
return this.page.getByRole('button', { name: 'Remove recovery phone' });
106+
}
99107
}
100108

101109
export class ConnectedServicesRow extends UnitRow {
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import { expect } from '../../lib/fixtures/standard';
6+
import { SettingsLayout } from './layout';
7+
8+
export class RecoveryPhoneSetupPage extends SettingsLayout {
9+
get path(): string {
10+
return '';
11+
}
12+
13+
// Methods for AddRecoveryPhone
14+
addHeader() {
15+
return this.page.locator('h2', { hasText: 'Verify your phone number' });
16+
}
17+
18+
get phoneNumberInput() {
19+
return this.page.locator('input[name="phoneNumber"]');
20+
}
21+
22+
get sendCodeButton() {
23+
return this.page.locator('button', { hasText: 'Send code' });
24+
}
25+
26+
get addErrorBanner() {
27+
return this.page.locator('#flow-setup-phone-submit-number-error');
28+
}
29+
30+
get backButton() {
31+
return this.page.locator('button', { hasText: 'Back to settings' });
32+
}
33+
34+
async enterPhoneNumber(phoneNumber: string) {
35+
await this.phoneNumberInput.fill(phoneNumber);
36+
}
37+
38+
async clickSendCode() {
39+
await this.sendCodeButton.click();
40+
}
41+
42+
async expectAddErrorBanner(message: string) {
43+
await expect(this.addErrorBanner).toHaveText(message);
44+
}
45+
46+
// Methods for ConfirmRecoveryPhone
47+
get confirmHeader() {
48+
return this.page.locator('h2', { hasText: 'Verify your phone number' });
49+
}
50+
51+
get codeInput() {
52+
return this.page.locator('input[name="code"]');
53+
}
54+
55+
get confirmButton() {
56+
return this.page.locator('button', { hasText: 'Confirm' });
57+
}
58+
59+
get resendCodeButton() {
60+
return this.page.locator('button', { hasText: 'Resend code' });
61+
}
62+
63+
get confirmErrorBanner() {
64+
return this.page.locator('#flow-setup-phone-confirm-code-error');
65+
}
66+
67+
async enterCode(code: string) {
68+
await this.codeInput.fill(code);
69+
}
70+
71+
async clickConfirm() {
72+
await this.confirmButton.click();
73+
}
74+
75+
async clickResendCode() {
76+
await this.resendCodeButton.click();
77+
}
78+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import { BaseLayout } from './layout';
6+
import { expect } from '@playwright/test';
7+
8+
export class SigninRecoveryChoicePage extends BaseLayout {
9+
readonly path = '/signin_recovery_choice';
10+
11+
get heading() {
12+
return this.page.getByRole('heading', { name: 'Sign in' });
13+
}
14+
15+
get errorBanner() {
16+
return this.page.locator('.banner.error');
17+
}
18+
19+
get phoneChoice() {
20+
return this.page.locator('.input-radio-wrapper').first();
21+
}
22+
23+
get codeChoice() {
24+
return this.page.locator('.input-radio-wrapper').nth(1);
25+
}
26+
27+
get backButton() {
28+
return this.page.getByRole('button', { name: 'Back' });
29+
}
30+
31+
get continueButton() {
32+
return this.page.getByRole('button', { name: 'Continue' });
33+
}
34+
35+
async clickChoosePhone() {
36+
await this.phoneChoice.click();
37+
}
38+
39+
async clickChooseCode() {
40+
await this.codeChoice.click();
41+
}
42+
43+
async clickContinue() {
44+
await this.continueButton.click();
45+
}
46+
47+
async clickBack() {
48+
await this.backButton.click();
49+
}
50+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import { BaseTokenCodePage } from './baseTokenCode';
6+
7+
export class SigninRecoveryPhonePage extends BaseTokenCodePage {
8+
readonly path = '/signin_recovery_phone';
9+
10+
get codeInput() {
11+
this.checkPath();
12+
return this.page
13+
.getByLabel('Enter 6-digit code') // React
14+
.or(this.page.getByPlaceholder('Enter 6-digit code')); // Backbone
15+
}
16+
17+
get resendCodeButton() {
18+
return this.page.getByRole('button', { name: 'Resend code' });
19+
}
20+
21+
get confirmButton() {
22+
return this.page.getByRole('button', { name: 'Confirm' });
23+
}
24+
25+
get backButton() {
26+
return this.page.getByRole('button', { name: 'Back' });
27+
}
28+
29+
get lockedOutLink() {
30+
return this.page.getByRole('link', { name: 'Are you locked out?' });
31+
}
32+
33+
async enterCode(code: string) {
34+
await this.codeInput.fill(code);
35+
}
36+
37+
async clickResendCode() {
38+
await this.resendCodeButton.click();
39+
}
40+
41+
async clickConfirm() {
42+
await this.confirmButton.click();
43+
}
44+
45+
async clickBack() {
46+
await this.backButton.click();
47+
}
48+
49+
async clickLockedOutLink() {
50+
await this.lockedOutLink.click();
51+
}
52+
}

packages/functional-tests/pages/signinTotpCode.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,13 @@ export class SigninTotpCodePage extends BaseTokenCodePage {
1818
this.checkPath();
1919
return this.page.getByRole('link', { name: 'Trouble entering code?' });
2020
}
21+
22+
get troubleEnteringCode() {
23+
return this.page.getByRole('link', { name: 'Trouble entering code?' });
24+
}
25+
26+
async clickTroubleEnteringCode() {
27+
this.checkPath();
28+
return this.troubleEnteringCode.click();
29+
}
2130
}

0 commit comments

Comments
 (0)