123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417 |
- import {AccountEmails} from 'sentry-fixture/accountEmails';
- import {Authenticators} from 'sentry-fixture/authenticators';
- import {Organizations} from 'sentry-fixture/organizations';
- import {
- render,
- renderGlobalModal,
- screen,
- userEvent,
- waitFor,
- } from 'sentry-test/reactTestingLibrary';
- import ModalStore from 'sentry/stores/modalStore';
- import AccountSecurity from 'sentry/views/settings/account/accountSecurity';
- import AccountSecurityWrapper from 'sentry/views/settings/account/accountSecurity/accountSecurityWrapper';
- const ENDPOINT = '/users/me/authenticators/';
- const ORG_ENDPOINT = '/organizations/';
- const ACCOUNT_EMAILS_ENDPOINT = '/users/me/emails/';
- const AUTH_ENDPOINT = '/auth/';
- describe('AccountSecurity', function () {
- const router = TestStubs.router();
- beforeEach(function () {
- jest.spyOn(window.location, 'assign').mockImplementation(() => {});
- MockApiClient.clearMockResponses();
- MockApiClient.addMockResponse({
- url: ORG_ENDPOINT,
- body: Organizations(),
- });
- MockApiClient.addMockResponse({
- url: ACCOUNT_EMAILS_ENDPOINT,
- body: AccountEmails(),
- });
- });
- afterEach(function () {
- (window.location.assign as jest.Mock).mockRestore();
- });
- function renderComponent() {
- return render(
- <AccountSecurityWrapper
- location={router.location}
- route={router.routes[0]}
- routes={router.routes}
- router={router}
- routeParams={router.params}
- params={{...router.params, authId: '15'}}
- >
- <AccountSecurity
- deleteDisabled={false}
- authenticators={[]}
- hasVerifiedEmail
- countEnrolled={0}
- handleRefresh={jest.fn()}
- onDisable={jest.fn()}
- orgsRequire2fa={[]}
- location={router.location}
- route={router.routes[0]}
- routes={router.routes}
- router={router}
- routeParams={router.params}
- params={{...router.params, authId: '15'}}
- />
- </AccountSecurityWrapper>,
- {context: TestStubs.routerContext()}
- );
- }
- it('renders empty', async function () {
- MockApiClient.addMockResponse({
- url: ENDPOINT,
- body: [],
- });
- renderComponent();
- expect(
- await screen.findByText('No available authenticators to add')
- ).toBeInTheDocument();
- });
- it('renders a primary interface that is enrolled', async function () {
- MockApiClient.addMockResponse({
- url: ENDPOINT,
- body: [Authenticators().Totp({configureButton: 'Info'})],
- });
- renderComponent();
- expect(await screen.findByText('Authenticator App')).toBeInTheDocument();
- expect(screen.getByRole('button', {name: 'Info'})).toBeInTheDocument();
- expect(screen.getByRole('button', {name: 'Delete'})).toBeInTheDocument();
- expect(
- screen.getByRole('status', {name: 'Authentication Method Active'})
- ).toBeInTheDocument();
- });
- it('can delete enrolled authenticator', async function () {
- MockApiClient.addMockResponse({
- url: ENDPOINT,
- body: [
- Authenticators().Totp({
- authId: '15',
- configureButton: 'Info',
- }),
- ],
- });
- const deleteMock = MockApiClient.addMockResponse({
- url: `${ENDPOINT}15/`,
- method: 'DELETE',
- });
- renderComponent();
- expect(deleteMock).not.toHaveBeenCalled();
- expect(
- await screen.findByRole('status', {name: 'Authentication Method Active'})
- ).toBeInTheDocument();
- // next authenticators request should have totp disabled
- const authenticatorsMock = MockApiClient.addMockResponse({
- url: ENDPOINT,
- body: [
- Authenticators().Totp({
- isEnrolled: false,
- authId: '15',
- configureButton: 'Info',
- }),
- ],
- });
- await userEvent.click(screen.getByRole('button', {name: 'Delete'}));
- renderGlobalModal();
- await userEvent.click(screen.getByTestId('confirm-button'));
- // Should only have been called once
- await waitFor(() => expect(authenticatorsMock).toHaveBeenCalledTimes(1));
- expect(deleteMock).toHaveBeenCalled();
- expect(
- screen.getByRole('status', {name: 'Authentication Method Inactive'})
- ).toBeInTheDocument();
- });
- it('can remove one of multiple 2fa methods when org requires 2fa', async function () {
- MockApiClient.addMockResponse({
- url: ENDPOINT,
- body: [
- Authenticators().Totp({
- authId: '15',
- configureButton: 'Info',
- }),
- Authenticators().U2f(),
- ],
- });
- MockApiClient.addMockResponse({
- url: ORG_ENDPOINT,
- body: Organizations({require2FA: true}),
- });
- const deleteMock = MockApiClient.addMockResponse({
- url: `${ENDPOINT}15/`,
- method: 'DELETE',
- });
- expect(deleteMock).not.toHaveBeenCalled();
- renderComponent();
- expect(
- await screen.findAllByRole('status', {name: 'Authentication Method Active'})
- ).toHaveLength(2);
- await userEvent.click(screen.getAllByRole('button', {name: 'Delete'})[0]);
- renderGlobalModal();
- await userEvent.click(screen.getByTestId('confirm-button'));
- expect(deleteMock).toHaveBeenCalled();
- });
- it('can not remove last 2fa method when org requires 2fa', async function () {
- MockApiClient.addMockResponse({
- url: ENDPOINT,
- body: [
- Authenticators().Totp({
- authId: '15',
- configureButton: 'Info',
- }),
- ],
- });
- MockApiClient.addMockResponse({
- url: ORG_ENDPOINT,
- body: Organizations({require2FA: true}),
- });
- const deleteMock = MockApiClient.addMockResponse({
- url: `${ENDPOINT}15/`,
- method: 'DELETE',
- });
- renderComponent();
- expect(deleteMock).not.toHaveBeenCalled();
- expect(
- await screen.findByRole('status', {name: 'Authentication Method Active'})
- ).toBeInTheDocument();
- await userEvent.hover(screen.getByRole('button', {name: 'Delete'}));
- expect(screen.getByRole('button', {name: 'Delete'})).toBeDisabled();
- expect(
- await screen.findByText(
- 'Two-factor authentication is required for organization(s): test 1 and test 2.'
- )
- ).toBeInTheDocument();
- });
- it('cannot enroll without verified email', async function () {
- MockApiClient.addMockResponse({
- url: ENDPOINT,
- body: [Authenticators().Totp({isEnrolled: false})],
- });
- MockApiClient.addMockResponse({
- url: ACCOUNT_EMAILS_ENDPOINT,
- body: [
- {
- email: 'primary@example.com',
- isPrimary: true,
- isVerified: false,
- },
- ],
- });
- renderComponent();
- const openEmailModalFunc = jest.spyOn(ModalStore, 'openModal');
- expect(
- await screen.findByRole('status', {name: 'Authentication Method Inactive'})
- ).toBeInTheDocument();
- await userEvent.click(screen.getByRole('button', {name: 'Add'}));
- await waitFor(() => expect(openEmailModalFunc).toHaveBeenCalled());
- });
- it('renders a backup interface that is not enrolled', async function () {
- MockApiClient.addMockResponse({
- url: ENDPOINT,
- body: [Authenticators().Recovery({isEnrolled: false})],
- });
- renderComponent();
- expect(
- await screen.findByRole('status', {name: 'Authentication Method Inactive'})
- ).toBeInTheDocument();
- expect(screen.getByText('Recovery Codes')).toBeInTheDocument();
- });
- it('renders a primary interface that is not enrolled', async function () {
- MockApiClient.addMockResponse({
- url: ENDPOINT,
- body: [Authenticators().Totp({isEnrolled: false})],
- });
- renderComponent();
- expect(
- await screen.findByRole('status', {name: 'Authentication Method Inactive'})
- ).toBeInTheDocument();
- expect(screen.getByText('Authenticator App')).toBeInTheDocument();
- });
- it('does not render primary interface that disallows new enrollments', async function () {
- MockApiClient.addMockResponse({
- url: ENDPOINT,
- body: [
- Authenticators().Totp({disallowNewEnrollment: false}),
- Authenticators().U2f({disallowNewEnrollment: undefined}),
- Authenticators().Sms({disallowNewEnrollment: true}),
- ],
- });
- renderComponent();
- expect(await screen.findByText('Authenticator App')).toBeInTheDocument();
- expect(screen.getByText('U2F (Universal 2nd Factor)')).toBeInTheDocument();
- expect(screen.queryByText('Text Message')).not.toBeInTheDocument();
- });
- it('renders primary interface if new enrollments are disallowed, but we are enrolled', async function () {
- MockApiClient.addMockResponse({
- url: ENDPOINT,
- body: [Authenticators().Sms({isEnrolled: true, disallowNewEnrollment: true})],
- });
- renderComponent();
- // Should still render the authenticator since we are already enrolled
- expect(await screen.findByText('Text Message')).toBeInTheDocument();
- });
- it('renders a backup interface that is enrolled', async function () {
- MockApiClient.addMockResponse({
- url: ENDPOINT,
- body: [Authenticators().Recovery({isEnrolled: true})],
- });
- renderComponent();
- expect(await screen.findByText('Recovery Codes')).toBeInTheDocument();
- expect(screen.getByRole('button', {name: 'View Codes'})).toBeEnabled();
- });
- it('can change password', async function () {
- MockApiClient.addMockResponse({
- url: ENDPOINT,
- body: [Authenticators().Recovery({isEnrolled: false})],
- });
- const url = '/users/me/password/';
- const mock = MockApiClient.addMockResponse({
- url,
- method: 'PUT',
- });
- renderComponent();
- await userEvent.type(
- await screen.findByRole('textbox', {name: 'Current Password'}),
- 'oldpassword'
- );
- await userEvent.type(
- screen.getByRole('textbox', {name: 'New Password'}),
- 'newpassword'
- );
- await userEvent.type(
- screen.getByRole('textbox', {name: 'Verify New Password'}),
- 'newpassword'
- );
- await userEvent.click(screen.getByRole('button', {name: 'Change password'}));
- expect(mock).toHaveBeenCalledWith(
- url,
- expect.objectContaining({
- method: 'PUT',
- data: {
- password: 'oldpassword',
- passwordNew: 'newpassword',
- passwordVerify: 'newpassword',
- },
- })
- );
- });
- it('requires current password to be entered', async function () {
- MockApiClient.addMockResponse({
- url: ENDPOINT,
- body: [Authenticators().Recovery({isEnrolled: false})],
- });
- const url = '/users/me/password/';
- const mock = MockApiClient.addMockResponse({
- url,
- method: 'PUT',
- });
- renderComponent();
- await userEvent.type(
- await screen.findByRole('textbox', {name: 'New Password'}),
- 'newpassword'
- );
- await userEvent.type(
- screen.getByRole('textbox', {name: 'Verify New Password'}),
- 'newpassword'
- );
- await userEvent.click(screen.getByRole('button', {name: 'Change password'}));
- expect(mock).not.toHaveBeenCalled();
- });
- it('can expire all sessions', async function () {
- MockApiClient.addMockResponse({
- url: ENDPOINT,
- body: [Authenticators().Recovery({isEnrolled: false})],
- });
- const mock = MockApiClient.addMockResponse({
- url: AUTH_ENDPOINT,
- body: {all: true},
- method: 'DELETE',
- status: 204,
- });
- renderComponent();
- await userEvent.click(
- await screen.findByRole('button', {name: 'Sign out of all devices'})
- );
- expect(mock).toHaveBeenCalled();
- await waitFor(() =>
- expect(window.location.assign).toHaveBeenCalledWith('/auth/login/')
- );
- });
- });
|