mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
495 lines
20 KiB
TypeScript
495 lines
20 KiB
TypeScript
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
import { act } from 'react';
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { LinkDeviceSection, AccountSecuritySettings } from '../account-security-settings';
|
|
import { useAccountStore, type AccountEntry } from '@/stores/account-store';
|
|
import { useAccountSecurityStore } from '@/stores/account-security-store';
|
|
import { useAuthStore } from '@/stores/auth-store';
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
lite: { value: false },
|
|
oauthEnabled: { value: false },
|
|
apiFetch: vi.fn(),
|
|
toDataURL: vi.fn(async () => 'data:image/png;base64,QR'),
|
|
}));
|
|
|
|
// A stable `t` (keys come back as-is) that also has `t.raw` for the
|
|
// "not available" note.
|
|
vi.mock('next-intl', () => {
|
|
const t = Object.assign((key: string) => key, { raw: (key: string) => key });
|
|
return { useTranslations: () => t, useLocale: () => 'en' };
|
|
});
|
|
|
|
vi.mock('@/lib/lite', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@/lib/lite')>();
|
|
return {
|
|
...actual,
|
|
get IS_LITE() {
|
|
return mocks.lite.value;
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock('@/lib/browser-navigation', async (importOriginal) => ({
|
|
...(await importOriginal<typeof import('@/lib/browser-navigation')>()),
|
|
apiFetch: mocks.apiFetch,
|
|
getPathPrefix: () => '',
|
|
}));
|
|
|
|
vi.mock('qrcode', () => ({ default: { toDataURL: mocks.toDataURL } }));
|
|
|
|
vi.mock('@/hooks/use-config', () => ({
|
|
useConfig: () => ({ oauthEnabled: mocks.oauthEnabled.value }),
|
|
}));
|
|
|
|
interface Reply {
|
|
status: number;
|
|
body: unknown;
|
|
}
|
|
|
|
const OK = (code = 'CODE-1', statusId = 'st-1', expiresIn = 120): Reply => ({
|
|
status: 200,
|
|
body: { pairing_code: code, status_id: statusId, expires_in: expiresIn },
|
|
});
|
|
const ERR = (status: number, error: string): Reply => ({ status, body: { error } });
|
|
|
|
let createReplies: Reply[] = [];
|
|
let pairStatus = 'pending';
|
|
let ssoStartStatus = 200;
|
|
|
|
function reply({ status, body }: Reply) {
|
|
return { ok: status >= 200 && status < 300, status, json: async () => body };
|
|
}
|
|
|
|
function callsTo(prefix: string) {
|
|
return mocks.apiFetch.mock.calls.filter(([url]) => String(url).startsWith(prefix));
|
|
}
|
|
const createBodies = () =>
|
|
callsTo('/api/auth/pair/create').map(([, init]) => JSON.parse((init as RequestInit).body as string));
|
|
const ssoBodies = () =>
|
|
callsTo('/api/auth/sso/start').map(([, init]) => JSON.parse((init as RequestInit).body as string));
|
|
const statusCalls = () => callsTo('/api/auth/pair/status');
|
|
|
|
function setAccount(providerSession?: boolean) {
|
|
const entry = {
|
|
id: 'me@example.com@mail.example.com',
|
|
label: 'me@example.com',
|
|
serverUrl: 'https://mail.example.com',
|
|
username: 'me@example.com',
|
|
authMode: providerSession ? 'oauth' : 'basic',
|
|
cookieSlot: 2,
|
|
rememberMe: false,
|
|
providerSession,
|
|
displayName: '',
|
|
email: 'me@example.com',
|
|
avatarColor: '#000000',
|
|
lastLoginAt: 0,
|
|
isConnected: true,
|
|
hasError: false,
|
|
isDefault: true,
|
|
} as AccountEntry;
|
|
useAccountStore.setState({ accounts: [entry], activeAccountId: entry.id });
|
|
}
|
|
|
|
const origin = () => window.location.origin;
|
|
const expectedBase = () => ({
|
|
slot: 2,
|
|
webmail_base: origin(),
|
|
redirect_uri: `${origin()}/en/auth/callback`,
|
|
});
|
|
const announcement = (container: HTMLElement) =>
|
|
container.querySelector('p.sr-only[aria-live="polite"]')?.textContent ?? '';
|
|
|
|
function clickGenerate() {
|
|
fireEvent.click(screen.getByRole('button', { name: 'link_device.generate' }));
|
|
}
|
|
|
|
async function submitPassword(password: string, totp?: string) {
|
|
fireEvent.change(await screen.findByLabelText('password.current'), { target: { value: password } });
|
|
if (totp !== undefined) {
|
|
fireEvent.change(screen.getByLabelText('totp.verification_code'), { target: { value: totp } });
|
|
}
|
|
fireEvent.click(screen.getByRole('button', { name: 'link_device.continue' }));
|
|
}
|
|
|
|
async function advance(ms: number) {
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(ms);
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
createReplies = [];
|
|
pairStatus = 'pending';
|
|
ssoStartStatus = 200;
|
|
mocks.lite.value = false;
|
|
mocks.oauthEnabled.value = false;
|
|
mocks.apiFetch.mockImplementation(async (url: string) => {
|
|
if (url === '/api/auth/pair/create') {
|
|
return reply(createReplies.shift() ?? ERR(500, 'unexpected_call'));
|
|
}
|
|
if (url.startsWith('/api/auth/pair/status')) return reply({ status: 200, body: { status: pairStatus } });
|
|
// A fragment-only URL keeps jsdom from attempting a real navigation.
|
|
if (url === '/api/auth/sso/start') return reply({ status: ssoStartStatus, body: { authorize_url: '#idp', state: 'sso-state-1' } });
|
|
throw new Error(`unexpected fetch ${url}`);
|
|
});
|
|
sessionStorage.clear();
|
|
useAccountSecurityStore.setState({ otpEnabled: false });
|
|
setAccount(false);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
window.history.replaceState(null, '', '/');
|
|
});
|
|
|
|
describe('LinkDeviceSection step-up', () => {
|
|
it('offers single sign-on from the password form when the server has it', async () => {
|
|
mocks.oauthEnabled.value = true;
|
|
createReplies = [ERR(401, 'reauth_required')];
|
|
render(<LinkDeviceSection />);
|
|
clickGenerate();
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'link_device.use_sso' }));
|
|
await waitFor(() => expect(ssoBodies()).toHaveLength(1));
|
|
expect(ssoBodies()[0]).toMatchObject({ purpose: 'reauth', slot: 2 });
|
|
expect(sessionStorage.getItem('pair_reauth_resume')).toBe('sso-state-1');
|
|
});
|
|
|
|
it('leaves no re-auth flag behind when the IdP round trip cannot start', async () => {
|
|
mocks.oauthEnabled.value = true;
|
|
ssoStartStatus = 500;
|
|
createReplies = [ERR(401, 'reauth_required')];
|
|
render(<LinkDeviceSection />);
|
|
clickGenerate();
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'link_device.use_sso' }));
|
|
expect(await screen.findByRole('alert')).toHaveTextContent('link_device.error');
|
|
expect(sessionStorage.getItem('pair_reauth_resume')).toBeNull();
|
|
});
|
|
|
|
it('shows no single sign-on option without it', async () => {
|
|
createReplies = [ERR(401, 'reauth_required')];
|
|
render(<LinkDeviceSection />);
|
|
clickGenerate();
|
|
|
|
await screen.findByLabelText('password.current');
|
|
expect(screen.queryByRole('button', { name: 'link_device.use_sso' })).toBeNull();
|
|
});
|
|
|
|
it('asks a password account for its password when the server wants a step-up', async () => {
|
|
createReplies = [ERR(401, 'reauth_required')];
|
|
render(<LinkDeviceSection />);
|
|
clickGenerate();
|
|
|
|
const password = await screen.findByLabelText('password.current');
|
|
expect(password).toHaveAttribute('type', 'password');
|
|
expect(password).toHaveAttribute('autocomplete', 'current-password');
|
|
expect(screen.getByText('link_device.password_prompt')).toBeInTheDocument();
|
|
expect(screen.queryByLabelText('totp.verification_code')).toBeNull();
|
|
expect(createBodies()).toEqual([expectedBase()]);
|
|
expect(ssoBodies()).toEqual([]);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'link_device.show_password' }));
|
|
expect(password).toHaveAttribute('type', 'text');
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'app_passwords.cancel' }));
|
|
expect(screen.queryByLabelText('password.current')).toBeNull();
|
|
expect(screen.getByRole('button', { name: 'link_device.generate' })).toBeInTheDocument();
|
|
});
|
|
|
|
it('sends the password with webmail_base and shows the QR and sign-in link', async () => {
|
|
createReplies = [ERR(401, 'reauth_required'), OK('ABC 123')];
|
|
render(<LinkDeviceSection />);
|
|
clickGenerate();
|
|
await submitPassword('hunter2');
|
|
|
|
const img = await screen.findByAltText('link_device.qr_alt');
|
|
expect(img).toHaveAttribute('src', 'data:image/png;base64,QR');
|
|
expect(createBodies()[1]).toEqual({ ...expectedBase(), password: 'hunter2' });
|
|
|
|
const link = `bulwarkmail://pair?server=${encodeURIComponent(origin())}&code=${encodeURIComponent('ABC 123')}`;
|
|
expect(mocks.toDataURL).toHaveBeenCalledWith(link, expect.anything());
|
|
expect(screen.getByLabelText('link_device.link_label')).toHaveValue(link);
|
|
expect(screen.getByText('link_device.instructions')).toBeInTheDocument();
|
|
expect(screen.getByText('link_device.copy_hint')).toBeInTheDocument();
|
|
expect(screen.getByText('link_device.expires_in')).toBeInTheDocument();
|
|
// The password form (and the password in it) is gone.
|
|
expect(screen.queryByLabelText('password.current')).toBeNull();
|
|
|
|
const writeText = vi.fn(async () => undefined);
|
|
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
|
|
fireEvent.click(screen.getByRole('button', { name: 'link_device.copy_link' }));
|
|
expect(writeText).toHaveBeenCalledWith(link);
|
|
expect(await screen.findByText('link_device.copied')).toBeInTheDocument();
|
|
});
|
|
|
|
it('sends an identity-provider account back to the IdP instead of asking for a password', async () => {
|
|
setAccount(true);
|
|
createReplies = [ERR(401, 'reauth_required')];
|
|
render(<LinkDeviceSection />);
|
|
clickGenerate();
|
|
|
|
await waitFor(() => expect(ssoBodies()).toHaveLength(1));
|
|
expect(ssoBodies()[0]).toMatchObject({
|
|
purpose: 'reauth',
|
|
slot: 2,
|
|
locale: 'en',
|
|
redirect_uri: `${origin()}/en/auth/callback`,
|
|
});
|
|
expect(sessionStorage.getItem('pair_reauth_resume')).toBe('sso-state-1');
|
|
expect(screen.queryByLabelText('password.current')).toBeNull();
|
|
});
|
|
|
|
it('does not bounce to the IdP again right after an IdP re-auth', async () => {
|
|
setAccount(true);
|
|
sessionStorage.setItem('pair_reauth_done', '1');
|
|
createReplies = [ERR(401, 'reauth_required')];
|
|
render(<LinkDeviceSection />);
|
|
|
|
expect(await screen.findByRole('alert')).toHaveTextContent('link_device.error');
|
|
expect(createBodies()).toEqual([expectedBase()]);
|
|
expect(ssoBodies()).toEqual([]);
|
|
expect(sessionStorage.getItem('pair_reauth_done')).toBeNull();
|
|
});
|
|
|
|
it('keeps the IdP resume when the section is remounted before the code arrives', async () => {
|
|
setAccount(true);
|
|
sessionStorage.setItem('pair_reauth_done', '1');
|
|
let release: () => void = () => {};
|
|
const held = new Promise<void>((resolve) => { release = resolve; });
|
|
createReplies = [OK('CODE-LOST'), OK('CODE-SHOWN')]; // whichever lands on the mounted section
|
|
const firstReply = mocks.apiFetch.getMockImplementation()!;
|
|
mocks.apiFetch.mockImplementationOnce(async (url: string, init?: RequestInit) => {
|
|
await held;
|
|
return firstReply(url, init);
|
|
});
|
|
|
|
// The Security tab unmounts the section while it probes the server.
|
|
const first = render(<LinkDeviceSection />);
|
|
first.unmount();
|
|
release();
|
|
expect(sessionStorage.getItem('pair_reauth_done')).toBe('1');
|
|
|
|
render(<LinkDeviceSection />);
|
|
const link = (await screen.findByLabelText('link_device.link_label')) as HTMLInputElement;
|
|
expect(link.value).toMatch(/^bulwarkmail:\/\/pair\?server=.+&code=CODE-(LOST|SHOWN)$/);
|
|
expect(sessionStorage.getItem('pair_reauth_done')).toBeNull();
|
|
expect(ssoBodies()).toEqual([]);
|
|
});
|
|
|
|
it('reveals the TOTP field on totp_required and blames password or code after a failed code', async () => {
|
|
createReplies = [
|
|
ERR(401, 'reauth_required'),
|
|
ERR(401, 'totp_required'),
|
|
ERR(401, 'totp_required'),
|
|
OK(),
|
|
];
|
|
render(<LinkDeviceSection />);
|
|
clickGenerate();
|
|
await submitPassword('hunter2');
|
|
|
|
const totp = await screen.findByLabelText('totp.verification_code');
|
|
expect(totp).toHaveAttribute('inputmode', 'numeric');
|
|
expect(totp).toHaveAttribute('autocomplete', 'one-time-code');
|
|
expect(screen.getByRole('status')).toHaveTextContent('link_device.totp_prompt');
|
|
expect(screen.getByLabelText('password.current')).toHaveValue('hunter2');
|
|
expect(createBodies()[1]).toEqual({ ...expectedBase(), password: 'hunter2' });
|
|
|
|
fireEvent.change(totp, { target: { value: '000000' } });
|
|
fireEvent.click(screen.getByRole('button', { name: 'link_device.continue' }));
|
|
expect(await screen.findByRole('alert')).toHaveTextContent('link_device.error_password_or_code');
|
|
expect(createBodies()[2]).toEqual({ ...expectedBase(), password: 'hunter2', totp: '000000' });
|
|
expect(screen.getByLabelText('totp.verification_code')).toHaveValue('');
|
|
|
|
fireEvent.change(screen.getByLabelText('totp.verification_code'), { target: { value: '123456' } });
|
|
fireEvent.click(screen.getByRole('button', { name: 'link_device.continue' }));
|
|
await screen.findByAltText('link_device.qr_alt');
|
|
expect(createBodies()[3]).toEqual({ ...expectedBase(), password: 'hunter2', totp: '123456' });
|
|
});
|
|
|
|
it('shows the TOTP field up front when two-factor auth is on', async () => {
|
|
useAccountSecurityStore.setState({ otpEnabled: true });
|
|
createReplies = [ERR(401, 'reauth_required'), OK()];
|
|
render(<LinkDeviceSection />);
|
|
clickGenerate();
|
|
|
|
fireEvent.change(await screen.findByLabelText('password.current'), { target: { value: 'hunter2' } });
|
|
const continueButton = screen.getByRole('button', { name: 'link_device.continue' });
|
|
expect(continueButton).toBeDisabled();
|
|
fireEvent.change(screen.getByLabelText('totp.verification_code'), { target: { value: '654321' } });
|
|
expect(continueButton).toBeEnabled();
|
|
fireEvent.click(continueButton);
|
|
|
|
await screen.findByAltText('link_device.qr_alt');
|
|
expect(createBodies()[1]).toEqual({ ...expectedBase(), password: 'hunter2', totp: '654321' });
|
|
});
|
|
|
|
it('says the password is wrong on invalid_credentials and clears it', async () => {
|
|
createReplies = [ERR(401, 'reauth_required'), ERR(401, 'invalid_credentials')];
|
|
render(<LinkDeviceSection />);
|
|
clickGenerate();
|
|
await submitPassword('wrong');
|
|
|
|
expect(await screen.findByRole('alert')).toHaveTextContent('link_device.error_wrong_password');
|
|
expect(screen.getByLabelText('password.current')).toHaveValue('');
|
|
});
|
|
|
|
it.each([
|
|
[401, 'not_signed_in', 'link_device.error_not_signed_in'],
|
|
[403, 'impersonation', 'link_device.error_impersonation'],
|
|
[429, 'too_many_attempts', 'link_device.error_too_many_attempts'],
|
|
[500, 'session_secret_required', 'link_device.error_session_secret'],
|
|
[502, 'server_unreachable', 'link_device.error_server_unreachable'],
|
|
[502, 'pairing_unavailable', 'link_device.error_pairing_unavailable'],
|
|
[400, 'insecure_server', 'link_device.error_insecure_server'],
|
|
[400, 'invalid_request', 'link_device.error'],
|
|
[500, 'something_else', 'link_device.error'],
|
|
])('maps %i %s to %s', async (status, error, key) => {
|
|
createReplies = [ERR(status, error)];
|
|
render(<LinkDeviceSection />);
|
|
clickGenerate();
|
|
expect(await screen.findByRole('alert')).toHaveTextContent(new RegExp(`^${key}$`));
|
|
});
|
|
|
|
it('shows the generic error when the request fails outright', async () => {
|
|
mocks.apiFetch.mockRejectedValueOnce(new TypeError('Failed to fetch'));
|
|
render(<LinkDeviceSection />);
|
|
clickGenerate();
|
|
expect(await screen.findByRole('alert')).toHaveTextContent(/^link_device\.error$/);
|
|
});
|
|
});
|
|
|
|
describe('LinkDeviceSection status polling', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
});
|
|
|
|
it('swaps the QR for a success panel once the phone redeems the code, then stops polling', async () => {
|
|
createReplies = [OK('C1', 'st-9'), ERR(401, 'reauth_required')];
|
|
const { container } = render(<LinkDeviceSection />);
|
|
clickGenerate();
|
|
await screen.findByAltText('link_device.qr_alt');
|
|
|
|
await advance(2000);
|
|
expect(statusCalls()).toHaveLength(1);
|
|
expect(statusCalls()[0][0]).toBe('/api/auth/pair/status?id=st-9');
|
|
expect(screen.getByAltText('link_device.qr_alt')).toBeInTheDocument();
|
|
|
|
pairStatus = 'redeemed';
|
|
await advance(2000);
|
|
await waitFor(() => expect(screen.getByText('link_device.success_body')).toBeInTheDocument());
|
|
expect(screen.queryByAltText('link_device.qr_alt')).toBeNull();
|
|
expect(announcement(container)).toBe('link_device.success_title');
|
|
|
|
const polled = statusCalls().length;
|
|
await advance(6000);
|
|
expect(statusCalls()).toHaveLength(polled);
|
|
|
|
// "Link another device" mints again; the used grant means a new step-up.
|
|
fireEvent.click(screen.getByRole('button', { name: 'link_device.link_another' }));
|
|
expect(await screen.findByLabelText('password.current')).toBeInTheDocument();
|
|
expect(createBodies()[1]).toEqual(expectedBase());
|
|
});
|
|
|
|
it('offers a new code when the server reports the code expired', async () => {
|
|
createReplies = [OK('C1', 'st-1'), OK('C2', 'st-2')];
|
|
const { container } = render(<LinkDeviceSection />);
|
|
clickGenerate();
|
|
await screen.findByAltText('link_device.qr_alt');
|
|
|
|
pairStatus = 'expired';
|
|
await advance(2000);
|
|
await waitFor(() => expect(announcement(container)).toBe('link_device.expired'));
|
|
expect(screen.queryByAltText('link_device.qr_alt')).toBeNull();
|
|
|
|
pairStatus = 'pending';
|
|
fireEvent.click(screen.getByRole('button', { name: 'link_device.regenerate' }));
|
|
await screen.findByAltText('link_device.qr_alt');
|
|
// Tried without a password first: the server may still hold a live grant.
|
|
expect(createBodies()[1]).toEqual(expectedBase());
|
|
|
|
await advance(2000);
|
|
expect(statusCalls().at(-1)?.[0]).toBe('/api/auth/pair/status?id=st-2');
|
|
});
|
|
|
|
it('treats an unknown status id as expired', async () => {
|
|
createReplies = [OK()];
|
|
const { container } = render(<LinkDeviceSection />);
|
|
clickGenerate();
|
|
await screen.findByAltText('link_device.qr_alt');
|
|
|
|
pairStatus = 'unknown';
|
|
await advance(2000);
|
|
await waitFor(() => expect(announcement(container)).toBe('link_device.expired'));
|
|
expect(screen.getByRole('button', { name: 'link_device.regenerate' })).toBeInTheDocument();
|
|
});
|
|
|
|
it('expires the code when the countdown runs out', async () => {
|
|
createReplies = [OK('C1', 'st-1', 3)];
|
|
const { container } = render(<LinkDeviceSection />);
|
|
clickGenerate();
|
|
await screen.findByAltText('link_device.qr_alt');
|
|
|
|
await advance(3000);
|
|
await waitFor(() => expect(announcement(container)).toBe('link_device.expired'));
|
|
expect(screen.queryByAltText('link_device.qr_alt')).toBeNull();
|
|
});
|
|
|
|
it('stops polling on unmount', async () => {
|
|
createReplies = [OK()];
|
|
const { unmount } = render(<LinkDeviceSection />);
|
|
clickGenerate();
|
|
await screen.findByAltText('link_device.qr_alt');
|
|
|
|
unmount();
|
|
await advance(6000);
|
|
expect(statusCalls()).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('AccountSecuritySettings link-device visibility', () => {
|
|
beforeEach(() => {
|
|
useAuthStore.setState({ isAuthenticated: false, authMode: 'basic', client: null });
|
|
});
|
|
|
|
it('shows the linker to password accounts on servers without account management', () => {
|
|
useAccountSecurityStore.setState({ isStalwart: false, isProbing: false });
|
|
render(<AccountSecuritySettings />);
|
|
expect(screen.getByText('link_device.title')).toBeInTheDocument();
|
|
expect(screen.getByText('not_available')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows the linker and the mail client setup to password accounts on Stalwart', () => {
|
|
// The client setup used to be OAuth-only; password users ask for it just as often.
|
|
useAccountSecurityStore.setState({ isStalwart: true, isProbing: false });
|
|
render(<AccountSecuritySettings />);
|
|
expect(screen.getByText('link_device.title')).toBeInTheDocument();
|
|
expect(screen.getByText('email_client.title')).toBeInTheDocument();
|
|
expect(screen.getByText('password.title')).toBeInTheDocument();
|
|
});
|
|
|
|
it('mounts the linker only once the server probe has a verdict', () => {
|
|
// A layout guessed before the verdict remounted the linker afterwards and
|
|
// threw away a QR that was already showing.
|
|
useAuthStore.setState({ isAuthenticated: true, authMode: 'oauth', client: null });
|
|
useAccountSecurityStore.setState({ isStalwart: null, isProbing: false });
|
|
const { rerender } = render(<AccountSecuritySettings />);
|
|
expect(screen.queryByText('link_device.title')).toBeNull();
|
|
expect(screen.getByText('detecting')).toBeInTheDocument();
|
|
|
|
act(() => useAccountSecurityStore.setState({ isStalwart: false }));
|
|
rerender(<AccountSecuritySettings />);
|
|
expect(screen.getByText('link_device.title')).toBeInTheDocument();
|
|
});
|
|
|
|
it('hides the linker in Bulwark Lite', () => {
|
|
mocks.lite.value = true;
|
|
useAccountSecurityStore.setState({ isStalwart: false, isProbing: false });
|
|
render(<AccountSecuritySettings />);
|
|
expect(screen.queryByText('link_device.title')).toBeNull();
|
|
expect(screen.getByText('not_available')).toBeInTheDocument();
|
|
});
|
|
});
|