Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import * as ModelsModule from '../../../models';
import * as CacheModule from '../../../lib/cache';
import * as SetPasswordModule from '.';
import { StoredAccountData } from '../../../lib/storage-utils';

import AuthClient from 'fxa-auth-client/browser';
import {
Expand Down Expand Up @@ -106,7 +107,7 @@ function mockModelsModule() {
}
// Call this when testing local storage
function mockCurrentAccount(
storedAccount = {
storedAccount: StoredAccountData = {
uid: MOCK_UID,
sessionToken: MOCK_SESSION_TOKEN,
email: MOCK_EMAIL,
Expand Down Expand Up @@ -238,6 +239,52 @@ describe('SetPassword-container', () => {
expect(SetPasswordModule.default).not.toHaveBeenCalled();
});

describe('password state gating', () => {
it('skips the check and renders when stored state is passwordless', async () => {
mockCurrentAccount({ ...MOCK_STORED_ACCOUNT, hasPassword: false });
render();
await waitFor(() => {
expect(SetPasswordModule.default).toHaveBeenCalled();
});
expect(mockAuthClient.accountStatus).not.toHaveBeenCalled();
expect(mockNavigate).not.toHaveBeenCalled();
});

it('skips the check and redirects to signin when stored state has a password', async () => {
mockCurrentAccount({ ...MOCK_STORED_ACCOUNT, hasPassword: true });
render();
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith('/signin', { replace: true });
});
expect(mockAuthClient.accountStatus).not.toHaveBeenCalled();
expect(SetPasswordModule.default).not.toHaveBeenCalled();
});

it('falls back to the accountStatus check when stored state is unknown', async () => {
mockCurrentAccount({ ...MOCK_STORED_ACCOUNT, hasPassword: undefined });
render();
await waitFor(() => {
expect(SetPasswordModule.default).toHaveBeenCalled();
});
expect(mockAuthClient.accountStatus).toHaveBeenCalledWith(
undefined,
MOCK_SESSION_TOKEN
);
});

it('treats a failed fallback check as no password and renders', async () => {
mockCurrentAccount({ ...MOCK_STORED_ACCOUNT, hasPassword: undefined });
mockAuthClient.accountStatus = jest
.fn()
.mockRejectedValue(new Error('network failure'));
render();
await waitFor(() => {
expect(SetPasswordModule.default).toHaveBeenCalled();
});
expect(mockNavigate).not.toHaveBeenCalled();
});
});

describe('calling createPassword', () => {
let fxaLoginSpy: jest.SpyInstance;
let fxaOAuthLoginSpy: jest.SpyInstance;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,45 +67,33 @@ const SetPasswordContainer = ({
flowQueryParams as unknown as Record<string, string>
);

// Trust the stored password state; fall back to a one-time accountStatus
// check only when it's unknown (e.g. third-party sign-in, stale/direct hit).
const knownHasPassword = storedLocalAccount?.hasPassword;
const passwordStateKnown = knownHasPassword !== undefined;

const [passwordStatus, setPasswordStatus] = useState<{
isLoading: boolean;
hasPassword: boolean;
}>({
isLoading: true,
hasPassword: false,
isLoading: !passwordStateKnown,
hasPassword: knownHasPassword ?? false,
});
const didRunPasswordStatusCheckRef = useRef(false);
const didCheckRef = useRef(false);
useEffect(() => {
if (didRunPasswordStatusCheckRef.current) return;
didRunPasswordStatusCheckRef.current = true;
const checkPasswordStatus = async () => {
if (sessionToken) {
try {
const status = await authClient.accountStatus(
undefined,
sessionToken
);
setPasswordStatus({
isLoading: false,
hasPassword: !!status.hasPassword,
});
} catch (error) {
// TODO? Might need to retry.
// Request to create a PW won't go through if they already have one.
setPasswordStatus({
isLoading: false,
hasPassword: false,
});
}
} else {
if (passwordStateKnown || !sessionToken || didCheckRef.current) return;
didCheckRef.current = true;
authClient
.accountStatus(undefined, sessionToken)
.then((status) =>
setPasswordStatus({
isLoading: false,
hasPassword: false,
});
}
};
checkPasswordStatus();
}, [authClient, sessionToken]);
hasPassword: !!status.hasPassword,
})
)
// On failure, treat as no password; the server rejects if one exists.
.catch(() => setPasswordStatus({ isLoading: false, hasPassword: false }));
}, [authClient, sessionToken, passwordStateKnown]);

const { finishOAuthFlowHandler, oAuthDataError } = useFinishOAuthFlowHandler(
authClient,
Expand Down Expand Up @@ -234,10 +222,7 @@ const SetPasswordContainer = ({
return <AppLayout cmsInfo={integration.getCmsInfo()} loading />;
}

// User already has a password, redirect to signin.
// This can happen when a user signs into the browser with third party
// oauth and they try to use Sync or Send Tab later, but keys are missing.
// Firefox will send users to this page to set a password and receive keys.
// Already has a password (re-entry): sign in instead.
if (passwordStatus.hasPassword) {
navigateWithQuery('/signin', { replace: true });
return <AppLayout cmsInfo={integration.getCmsInfo()} loading />;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ const SigninPasswordlessCode = ({
// Update verification status of stored current account
verified: isSessionVerified,
sessionVerified: isSessionVerified,
hasPassword: false,
});

const navigationOptions = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { navigate } from '@reach/router';
import { OAUTH_ERRORS } from '../../../lib/oauth';
import userEvent from '@testing-library/user-event';
import * as SigninUtils from '../utils';
import * as StorageUtils from '../../../lib/storage-utils';

jest.mock('../../../lib/metrics', () => ({
usePageViewEvent: jest.fn(),
Expand Down Expand Up @@ -414,4 +415,34 @@ describe('Sign in with TOTP code page', () => {
});
});
});

describe('recording password state for set-password', () => {
it('records hasPassword: false on a passwordless OTP sign-in', async () => {
const user = userEvent.setup();
jest.spyOn(SigninUtils, 'handleNavigation').mockResolvedValue({
error: undefined,
});
const storeAccountDataSpy = jest
.spyOn(StorageUtils, 'storeAccountData')
.mockImplementation(() => {});
const submitTotpCode = jest.fn().mockResolvedValue({ error: undefined });
renderWithLocalizationProvider(
<Subject
submitTotpCode={submitTotpCode}
signinState={{
...MOCK_TOTP_LOCATION_STATE,
isPasswordlessOtpSignin: true,
}}
/>
);
await user.type(screen.getByLabelText('Enter 6-digit code'), '123456');
await user.click(screen.getByRole('button', { name: 'Confirm' }));

await waitFor(() => {
expect(storeAccountDataSpy).toHaveBeenCalledWith(
expect.objectContaining({ hasPassword: false })
);
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ export const SigninTotpCode = ({
// Update verification status of stored current account
verified: true,
sessionVerified: true,
...(signinState.isPasswordlessOtpSignin && { hasPassword: false }),
});

// Passwordless Sync flow: after TOTP, redirect to set_password
Expand Down
42 changes: 42 additions & 0 deletions packages/fxa-settings/src/pages/Signin/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
createMockSigninOAuthNativeSyncIntegration,
createMockSigninOAuthNativeIntegration,
createMockSigninOAuthIntegration,
createMockSigninWebIntegration,
} from './mocks';
import {
handleNavigation,
Expand Down Expand Up @@ -137,6 +138,47 @@ describe('Signin utils', () => {
expect(fxaLoginSpy).not.toHaveBeenCalled();
});

it('replaces history on a set-password completion so Back cannot return to it', async () => {
const navigationOptions = createBaseNavigationOptions({
integration: {
...createMockSigninWebIntegration(),
isSync: () => true,
},
queryParams: '?service=sync',
showSignupConfirmedSync: true,
origin: 'post-verify-set-password',
performNavigation: true,
handleFxaLogin: true,
});

await handleNavigation(navigationOptions);

expect(navigateSpy).toHaveBeenCalledWith(
expect.stringContaining('/signup_confirmed_sync'),
expect.objectContaining({ replace: true })
);
});

it('does not replace history for a Sync sign-in that is not a set-password completion', async () => {
const navigationOptions = createBaseNavigationOptions({
integration: {
...createMockSigninWebIntegration(),
isSync: () => true,
},
queryParams: '?service=sync',
showSignupConfirmedSync: true,
performNavigation: true,
handleFxaLogin: true,
});

await handleNavigation(navigationOptions);

expect(navigateSpy).toHaveBeenCalledWith(
expect.stringContaining('/signup_confirmed_sync'),
expect.objectContaining({ replace: false })
);
});

describe('unverified session navigation', () => {
it('returns early for SIGN_UP verification reason', async () => {
const navigationOptions = createBaseNavigationOptions({
Expand Down
7 changes: 6 additions & 1 deletion packages/fxa-settings/src/pages/Signin/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,12 @@ export async function handleNavigation(navigationOptions: NavigationOptions) {
if (navigationOptions.performNavigation !== false) {
const { to, locationState, shouldHardNavigate } =
await getNonOAuthNavigationTarget(navigationOptions);
performNavigation({ to, locationState, shouldHardNavigate });
performNavigation({
to,
locationState,
shouldHardNavigate,
replace: navigationOptions.origin === 'post-verify-set-password',
});
}
return { error: undefined };
}
Expand Down
Loading