Skip to content

Commit 0385afa

Browse files
authored
Merge pull request #19546 from mozilla/FXA-12472
fix(settings): Redirect users from /settings if session is unverified
2 parents 6a9325a + 8f25961 commit 0385afa

7 files changed

Lines changed: 95 additions & 56 deletions

File tree

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

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,31 +2,28 @@
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 { ElementHandle, Page } from '@playwright/test';
5+
import { Locator, Page } from '@playwright/test';
66

77
export class ConnectedService {
88
name = '';
99
constructor(
10-
readonly element: ElementHandle<Node>,
10+
readonly element: Locator,
1111
readonly page: Page
1212
) {}
1313

14-
static async create(
15-
element: ElementHandle<Node>,
16-
page: Page
17-
) {
14+
static async create(element: Locator, page: Page) {
1815
const service = new ConnectedService(element, page);
1916
service.name = await service.getName();
2017
return service;
2118
}
2219

2320
async getName() {
24-
const p = await this.element.waitForSelector('[data-testid=service-name]');
21+
const p = this.element.locator('[data-testid=service-name]');
2522
return p.innerText();
2623
}
2724

2825
async signout() {
29-
const button = await this.element.waitForSelector(
26+
const button = this.element.locator(
3027
'[data-testid=connected-service-sign-out]'
3128
);
3229
return button.click();

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,13 +123,17 @@ export class TotpRow extends UnitRow {
123123

124124
export class ConnectedServicesRow extends UnitRow {
125125
async services() {
126-
const elements = this.page.locator('[data-testid=settings-connected-service]');
126+
const elements = this.page.locator(
127+
'[data-testid=settings-connected-service]'
128+
);
127129
// there should be multiple, but you cannot call `.waitFor()` on a locator
128130
// that returns multiple elements.
129131
await elements.first().waitFor({ state: 'attached' });
130-
const elementHandles = await elements.elementHandles();
132+
const count = await elements.count();
131133
return Promise.all(
132-
elementHandles.map((el) => ConnectedService.create(el, this.page))
134+
Array.from({ length: count }, (_, i) =>
135+
ConnectedService.create(elements.nth(i), this.page)
136+
)
133137
);
134138
}
135139
}

packages/fxa-settings/src/components/App/index.test.tsx

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import React, { ReactNode } from 'react';
66
import { act, screen, waitFor } from '@testing-library/react';
77
import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider';
8+
import { navigate } from '@reach/router';
89
import App from '.';
910
import * as Metrics from '../../lib/metrics';
1011
import {
@@ -29,12 +30,18 @@ import { firefox } from '../../lib/channels/firefox';
2930

3031
import GleanMetrics from '../../lib/glean';
3132
import config from '../../lib/config';
32-
import * as utils from 'fxa-react/lib/utils';
3333
import { currentAccount } from '../../lib/cache';
3434
import { MozServices } from '../../lib/types';
3535
import mockUseSyncEngines from '../../lib/hooks/useSyncEngines/mocks';
3636
import useSyncEngines from '../../lib/hooks/useSyncEngines';
3737

38+
jest.mock('@reach/router', () => {
39+
return {
40+
...jest.requireActual('@reach/router'),
41+
navigate: jest.fn(),
42+
};
43+
});
44+
3845
jest.mock('../../lib/hooks/useSyncEngines', () => ({
3946
__esModule: true,
4047
default: jest.fn(),
@@ -269,6 +276,7 @@ describe('loading spinner states', () => {
269276
});
270277
(useSession as jest.Mock).mockReturnValue({
271278
isValid: () => true,
279+
isSessionVerified: () => Promise.resolve(true),
272280
});
273281
(currentAccount as jest.Mock).mockReturnValueOnce({
274282
uid: 123,
@@ -368,21 +376,10 @@ describe('AuthAndAccountSetupRoutes', () => {
368376
});
369377

370378
describe('SettingsRoutes', () => {
371-
let hardNavigateSpy: jest.SpyInstance;
372379
const settingsPath = '/settings';
373-
jest.mock('@reach/router', () => ({
374-
...jest.requireActual('@reach/router'),
375-
useLocation: () => {
376-
return {
377-
pathname: settingsPath,
378-
};
379-
},
380-
}));
381380

382381
beforeEach(() => {
383-
hardNavigateSpy = jest
384-
.spyOn(utils, 'hardNavigate')
385-
.mockImplementation(() => {});
382+
(navigate as jest.Mock).mockClear();
386383
(useInitialMetricsQueryState as jest.Mock).mockReturnValue({
387384
loading: false,
388385
});
@@ -396,6 +393,7 @@ describe('SettingsRoutes', () => {
396393
});
397394
(useSession as jest.Mock).mockReturnValue({
398395
isValid: () => false,
396+
isSessionVerified: () => Promise.resolve(true),
399397
});
400398
(useProductInfoState as jest.Mock).mockReturnValue({
401399
loading: false,
@@ -409,7 +407,7 @@ describe('SettingsRoutes', () => {
409407
});
410408

411409
afterEach(() => {
412-
hardNavigateSpy.mockRestore();
410+
(navigate as jest.Mock).mockRestore();
413411
(useIntegration as jest.Mock).mockRestore();
414412
(useInitialMetricsQueryState as jest.Mock).mockRestore();
415413
(useLocalSignedInQueryState as jest.Mock).mockRestore();
@@ -449,7 +447,7 @@ describe('SettingsRoutes', () => {
449447
await act(() => navigateResult);
450448

451449
await waitFor(() => {
452-
expect(hardNavigateSpy).toHaveBeenCalledWith(
450+
expect(navigate).toHaveBeenCalledWith(
453451
`/?redirect_to=${encodeURIComponent(settingsPath)}`
454452
);
455453
});
@@ -493,7 +491,7 @@ describe('SettingsRoutes', () => {
493491
await act(() => navigateResult);
494492

495493
await waitFor(() => {
496-
expect(hardNavigateSpy).not.toHaveBeenCalled();
494+
expect(navigate).not.toHaveBeenCalled();
497495
});
498496

499497
expect(screen.getByText('Session Expired')).toBeInTheDocument();
@@ -529,6 +527,7 @@ describe('SettingsRoutes', () => {
529527

530528
(useSession as jest.Mock).mockReturnValue({
531529
isValid: () => true,
530+
isSessionVerified: () => Promise.resolve(true),
532531
});
533532

534533
let navigateResult: Promise<void>;
@@ -556,7 +555,7 @@ describe('SettingsRoutes', () => {
556555
await act(() => navigateResult);
557556

558557
await waitFor(() => {
559-
expect(hardNavigateSpy).not.toHaveBeenCalled();
558+
expect(navigate).not.toHaveBeenCalled();
560559
});
561560
expect(screen.getByTestId('settings-profile')).toBeInTheDocument();
562561
});
@@ -588,7 +587,7 @@ describe('SettingsRoutes', () => {
588587
await act(() => navigateResult);
589588

590589
await waitFor(() => {
591-
expect(hardNavigateSpy).not.toHaveBeenCalled();
590+
expect(navigate).not.toHaveBeenCalled();
592591
});
593592
expect(screen.getByTestId('settings-profile')).toBeInTheDocument();
594593
});

packages/fxa-settings/src/components/App/index.tsx

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
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 { RouteComponentProps, Router, useLocation } from '@reach/router';
5+
import {
6+
navigate,
7+
RouteComponentProps,
8+
Router,
9+
useLocation,
10+
} from '@reach/router';
611
import {
712
lazy,
813
Suspense,
@@ -35,8 +40,6 @@ import {
3540
SettingsContext,
3641
} from '../../models/contexts/SettingsContext';
3742

38-
import { hardNavigate } from 'fxa-react/lib/utils';
39-
4043
import sentryMetrics from 'fxa-shared/sentry/browser';
4144

4245
// Components
@@ -348,12 +351,6 @@ export const App = ({
348351
return <LoadingSpinner fullScreen />;
349352
}
350353

351-
// If we're on settings route but user is not signed in, redirect immediately
352-
if (window.location.pathname?.includes('/settings') && !isSignedIn) {
353-
hardNavigate('/');
354-
return <LoadingSpinner fullScreen />;
355-
}
356-
357354
return (
358355
<Router basepath="/">
359356
<AuthAndAccountSetupRoutes
@@ -389,7 +386,7 @@ const SettingsRoutes = ({
389386
// For regular RP / web logins, maybe the session token expired. In this
390387
// case we just send them to the root.
391388
params.set('redirect_to', location.pathname);
392-
hardNavigate(`/?${params.toString()}`);
389+
navigate(`/?${params.toString()}`);
393390
}
394391

395392
return <LoadingSpinner fullScreen />;

packages/fxa-settings/src/components/Settings/index.test.tsx

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import React, { ReactNode } from 'react';
66
import { History } from '@reach/router';
7+
import { waitFor } from '@testing-library/react';
78
import { Account, AppContext, useInitialSettingsState } from '../../models';
89
import {
910
mockAppContext,
@@ -82,8 +83,8 @@ describe('Settings App', () => {
8283
expect(getByLabelText('Loading…')).toBeInTheDocument();
8384
});
8485

85-
it('renders `AppErrorDialog` component when settings query errors', () => {
86-
(useInitialSettingsState as jest.Mock).mockReturnValueOnce({
86+
it('renders `AppErrorDialog` component when settings query errors', async () => {
87+
(useInitialSettingsState as jest.Mock).mockReturnValue({
8788
error: { message: 'Error' },
8889
});
8990
const { getByRole } = renderWithRouter(
@@ -92,15 +93,19 @@ describe('Settings App', () => {
9293
</AppContext.Provider>
9394
);
9495

95-
expect(getByRole('heading', { level: 2 })).toHaveTextContent(
96-
'General application error'
97-
);
96+
await waitFor(() => {
97+
expect(getByRole('heading', { level: 2 })).toHaveTextContent(
98+
'General application error'
99+
);
100+
});
98101
});
99102

100103
it('redirects to root if account is not verified', async () => {
101104
// this warning is expected, so we don't want to see it in the test output
102105
const warnSpy = jest.spyOn(console, 'warn').mockImplementation((msg) => {
103-
if (msg === 'Account verification is require to access /settings!')
106+
if (
107+
msg === 'Account or email verification is require to access /settings!'
108+
)
104109
return;
105110
});
106111
const unverifiedAccount = {
@@ -123,8 +128,34 @@ describe('Settings App', () => {
123128
{ route: SETTINGS_PATH }
124129
);
125130

126-
await Promise.resolve();
127-
expect(mockNavigateWithQuery).toHaveBeenCalledWith('/');
131+
await waitFor(() => {
132+
expect(mockNavigateWithQuery).toHaveBeenCalledWith('/');
133+
});
134+
warnSpy.mockRestore();
135+
});
136+
137+
it('redirects to root if session is not verified', async () => {
138+
// this warning is expected, so we don't want to see it in the test output
139+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation((msg) => {
140+
if (
141+
msg === 'Account or email verification is require to access /settings!'
142+
)
143+
return;
144+
});
145+
const unverifiedSession = mockSession(false);
146+
147+
renderWithRouter(
148+
<AppContext.Provider
149+
value={mockAppContext({ session: unverifiedSession })}
150+
>
151+
<Subject />
152+
</AppContext.Provider>,
153+
{ route: SETTINGS_PATH }
154+
);
155+
156+
await waitFor(() => {
157+
expect(mockNavigateWithQuery).toHaveBeenCalledWith('/');
158+
});
128159
warnSpy.mockRestore();
129160
});
130161

packages/fxa-settings/src/components/Settings/index.tsx

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
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 React, { useEffect } from 'react';
5+
import React, { useEffect, useState } from 'react';
66
import * as Sentry from '@sentry/browser';
77
import SettingsLayout from './SettingsLayout';
88
import LoadingSpinner from 'fxa-react/components/LoadingSpinner';
@@ -48,6 +48,7 @@ export const Settings = ({
4848
const account = useAccount();
4949
const location = useLocation();
5050
const navigateWithQuery = useNavigateWithQuery();
51+
const [sessionVerified, setSessionVerified] = useState<boolean | undefined>();
5152

5253
useEffect(() => {
5354
/**
@@ -121,7 +122,13 @@ export const Settings = ({
121122
gleanEnabled && GleanMetrics.pageLoad(location.pathname);
122123
}, [location.pathname, gleanEnabled]);
123124

124-
if (loading) {
125+
useEffect(() => {
126+
(async () => {
127+
setSessionVerified(await session.isSessionVerified());
128+
})();
129+
}, [session]);
130+
131+
if (loading || sessionVerified === undefined) {
125132
return <LoadingSpinner fullScreen />;
126133
}
127134

@@ -132,13 +139,14 @@ export const Settings = ({
132139
return <AppErrorDialog data-testid="error-dialog" />;
133140
}
134141

135-
// If the account email isn't verified, kick back to root to prompt for verification.
136-
// This should only happen if the user tries to access /settings directly
137-
// without entering a confirmation code on confirm_signup_code page.
138-
// Session verification requirement and redirect is handled on initial app load
139-
// (look for isUnverifiedSessionError in lib/gql.ts for that handling)
140-
if (account.primaryEmail.verified === false) {
141-
console.warn('Account verification is require to access /settings!');
142+
// If the account email isn't verified or the user is an unverified session state,
143+
// kick back to root to prompt for verification. This should only happen if the user
144+
// tries to access /settings directly without entering a confirmation code on
145+
// confirm_signup_code page or signin_token_code page.
146+
if (account.primaryEmail.verified === false || sessionVerified === false) {
147+
console.warn(
148+
'Account or email verification is require to access /settings!'
149+
);
142150
navigateWithQuery('/');
143151
return <LoadingSpinner fullScreen />;
144152
}

packages/fxa-settings/src/lib/gql.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ const isUnauthorizedError = (error: GraphQLError | GraphQLFormattedError) => {
4545
* not verified the login via email/2FA. Note, the email can be verified but not the
4646
* session. Only certain queries/API calls require a verified session.
4747
*
48+
* TODO: FXA-12454 do we still need this or at least can it be removed for the
49+
* '/settings' check?
50+
*
4851
* @param error
4952
*/
5053
const isUnverifiedSessionError = (

0 commit comments

Comments
 (0)