2026-09-26 15:12:43 +03:00
|
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
|
import { render, screen, waitFor } from '@testing-library/react';
|
|
|
|
|
import userEvent from '@testing-library/user-event';
|
|
|
|
|
import { QueryClientProvider } from '@tanstack/react-query';
|
|
|
|
|
|
|
|
|
|
import { PasskeysSection } from '@/pages/settings/PasskeysSection';
|
|
|
|
|
import {
|
|
|
|
|
base64URLToBuffer,
|
|
|
|
|
bufferToBase64URL,
|
|
|
|
|
createPasskey,
|
|
|
|
|
getPasskeyAssertion,
|
|
|
|
|
isPasskeySupported,
|
|
|
|
|
WebAuthnClientError,
|
|
|
|
|
} from '@/lib/webauthn';
|
|
|
|
|
import {
|
|
|
|
|
apiError,
|
|
|
|
|
createTestQueryClient,
|
|
|
|
|
findRequest,
|
|
|
|
|
installFetch,
|
|
|
|
|
json,
|
|
|
|
|
renderApp,
|
|
|
|
|
} from './helpers';
|
|
|
|
|
|
|
|
|
|
/** Кодирует строку в base64url — как это делает браузер для JSON-ответа. */
|
|
|
|
|
function base64URL(value: string): string {
|
|
|
|
|
return bufferToBase64URL(new TextEncoder().encode(value).buffer);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Подставной PublicKeyCredential: только то, что читает обёртка. */
|
|
|
|
|
function fakeCredential(overrides: Record<string, unknown> = {}) {
|
|
|
|
|
return {
|
|
|
|
|
id: 'credential-id',
|
|
|
|
|
rawId: new TextEncoder().encode('credential-id').buffer,
|
|
|
|
|
type: 'public-key',
|
|
|
|
|
authenticatorAttachment: 'platform',
|
|
|
|
|
getClientExtensionResults: () => ({}),
|
|
|
|
|
response: {
|
|
|
|
|
clientDataJSON: new TextEncoder().encode('{"type":"webauthn.create"}').buffer,
|
|
|
|
|
attestationObject: new TextEncoder().encode('attestation').buffer,
|
|
|
|
|
getTransports: () => ['internal'],
|
|
|
|
|
},
|
|
|
|
|
...overrides,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Устанавливает поддержку WebAuthn и мок navigator.credentials. */
|
|
|
|
|
function stubCredentials(credentials: {
|
|
|
|
|
create?: (options?: unknown) => Promise<unknown>;
|
|
|
|
|
get?: (options?: unknown) => Promise<unknown>;
|
|
|
|
|
}) {
|
|
|
|
|
vi.stubGlobal('PublicKeyCredential', function PublicKeyCredential() {});
|
|
|
|
|
const spies = {
|
|
|
|
|
create: vi.fn(credentials.create ?? (() => Promise.resolve(null))),
|
|
|
|
|
get: vi.fn(credentials.get ?? (() => Promise.resolve(null))),
|
|
|
|
|
};
|
|
|
|
|
Object.defineProperty(navigator, 'credentials', { value: spies, configurable: true });
|
|
|
|
|
return spies;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('обёртка WebAuthn', () => {
|
|
|
|
|
it('не поддерживается без PublicKeyCredential', () => {
|
|
|
|
|
vi.stubGlobal('PublicKeyCredential', undefined);
|
|
|
|
|
expect(isPasskeySupported()).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('переводит base64url в ArrayBuffer и обратно', () => {
|
|
|
|
|
const buffer = base64URLToBuffer('aGVsbG8');
|
|
|
|
|
expect(new TextDecoder().decode(buffer)).toBe('hello');
|
|
|
|
|
expect(bufferToBase64URL(buffer)).toBe('aGVsbG8');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('создаёт ключ и отдаёт ответ в формате сервера', async () => {
|
|
|
|
|
const spies = stubCredentials({ create: () => Promise.resolve(fakeCredential()) });
|
|
|
|
|
|
|
|
|
|
const result = await createPasskey({
|
|
|
|
|
challenge: base64URL('challenge-value'),
|
|
|
|
|
rp: { id: 'gl.mhspx.su', name: 'glchat' },
|
|
|
|
|
user: { id: base64URL('user-handle'), name: 'alice', displayName: 'Alice' },
|
|
|
|
|
pubKeyCredParams: [{ type: 'public-key', alg: -7 }],
|
|
|
|
|
excludeCredentials: [{ id: base64URL('old'), type: 'public-key', transports: ['internal'] }],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(result.type).toBe('public-key');
|
|
|
|
|
expect(result.response.attestationObject).toBe(base64URL('attestation'));
|
|
|
|
|
expect(result.response.transports).toEqual(['internal']);
|
|
|
|
|
// challenge уходит в браузер как ArrayBuffer, а не строкой.
|
|
|
|
|
const options = spies.create.mock.calls[0]?.[0] as {
|
|
|
|
|
publicKey: { challenge: ArrayBuffer; excludeCredentials: { id: ArrayBuffer }[] };
|
|
|
|
|
};
|
|
|
|
|
expect(options.publicKey.challenge).toBeInstanceOf(ArrayBuffer);
|
|
|
|
|
expect(options.publicKey.excludeCredentials[0]?.id).toBeInstanceOf(ArrayBuffer);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('возвращает понятный код при отмене пользователем', async () => {
|
|
|
|
|
stubCredentials({
|
|
|
|
|
create: () => Promise.reject(Object.assign(new Error('cancel'), { name: 'NotAllowedError' })),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
createPasskey({
|
|
|
|
|
challenge: base64URL('challenge'),
|
|
|
|
|
rp: { id: 'gl.mhspx.su', name: 'glchat' },
|
|
|
|
|
user: { id: base64URL('handle'), name: 'alice', displayName: 'Alice' },
|
|
|
|
|
pubKeyCredParams: [{ type: 'public-key', alg: -7 }],
|
|
|
|
|
}),
|
|
|
|
|
).rejects.toMatchObject({ code: 'cancelled' });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('сообщает об отсутствии поддержки до обращения к браузеру', async () => {
|
|
|
|
|
await expect(
|
|
|
|
|
createPasskey({
|
|
|
|
|
challenge: base64URL('challenge'),
|
|
|
|
|
rp: { id: 'gl.mhspx.su', name: 'glchat' },
|
|
|
|
|
user: { id: base64URL('handle'), name: 'alice', displayName: 'Alice' },
|
|
|
|
|
pubKeyCredParams: [{ type: 'public-key', alg: -7 }],
|
|
|
|
|
}),
|
|
|
|
|
).rejects.toBeInstanceOf(WebAuthnClientError);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('подписывает challenge и передаёт userHandle', async () => {
|
|
|
|
|
const assertion = {
|
|
|
|
|
id: 'credential-id',
|
|
|
|
|
rawId: new TextEncoder().encode('credential-id').buffer,
|
|
|
|
|
type: 'public-key',
|
|
|
|
|
authenticatorAttachment: null,
|
|
|
|
|
getClientExtensionResults: () => ({}),
|
|
|
|
|
response: {
|
|
|
|
|
clientDataJSON: new TextEncoder().encode('{"type":"webauthn.get"}').buffer,
|
|
|
|
|
authenticatorData: new TextEncoder().encode('auth-data').buffer,
|
|
|
|
|
signature: new TextEncoder().encode('signature').buffer,
|
|
|
|
|
userHandle: new TextEncoder().encode('handle').buffer,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
const spies = stubCredentials({ get: () => Promise.resolve(assertion) });
|
|
|
|
|
|
|
|
|
|
const result = await getPasskeyAssertion({ challenge: base64URL('challenge') });
|
|
|
|
|
expect(result.response.userHandle).toBe(base64URL('handle'));
|
|
|
|
|
expect(result.authenticatorAttachment).toBeUndefined();
|
|
|
|
|
expect(spies.get).toHaveBeenCalledTimes(1);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/** Рендер раздела настроек с изолированным QueryClient. */
|
|
|
|
|
function renderSection(ui: React.ReactElement) {
|
|
|
|
|
const queryClient = createTestQueryClient();
|
|
|
|
|
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('раздел «Ключи доступа»', () => {
|
|
|
|
|
it('без поддержки WebAuthn показывает понятное сообщение', async () => {
|
|
|
|
|
vi.stubGlobal('PublicKeyCredential', undefined);
|
|
|
|
|
installFetch([]);
|
|
|
|
|
|
|
|
|
|
renderSection(<PasskeysSection twoFactorEnabled={false} />);
|
|
|
|
|
|
|
|
|
|
expect(await screen.findByTestId('passkeys-unsupported')).toBeVisible();
|
|
|
|
|
expect(screen.queryByRole('button', { name: 'Создать ключ' })).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('добавляет ключ: step-up, ответ аутентификатора и обновление списка', async () => {
|
|
|
|
|
stubCredentials({ create: () => Promise.resolve(fakeCredential()) });
|
|
|
|
|
const fetchMock = installFetch([
|
|
|
|
|
{
|
|
|
|
|
match: '/api/v1/auth/passkeys/register/begin',
|
|
|
|
|
method: 'POST',
|
|
|
|
|
response: () =>
|
|
|
|
|
json({
|
|
|
|
|
options: {
|
|
|
|
|
publicKey: {
|
|
|
|
|
challenge: base64URL('challenge'),
|
|
|
|
|
rp: { id: 'gl.mhspx.su', name: 'glchat' },
|
|
|
|
|
user: { id: base64URL('handle'), name: 'alice', displayName: 'Alice' },
|
|
|
|
|
pubKeyCredParams: [{ type: 'public-key', alg: -7 }],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
ceremony: 'ceremony-1',
|
|
|
|
|
expires_in_sec: 300,
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
match: '/api/v1/auth/passkeys/register/finish',
|
|
|
|
|
method: 'POST',
|
|
|
|
|
response: () =>
|
|
|
|
|
json({
|
|
|
|
|
passkey: {
|
|
|
|
|
id: 'pk-1',
|
|
|
|
|
name: 'Ноутбук',
|
|
|
|
|
created_at: '2026-09-26T10:00:00Z',
|
|
|
|
|
sign_count: 0,
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
match: '/api/v1/auth/passkeys',
|
|
|
|
|
response: () => json({ passkeys: [], supported: true }),
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
renderSection(<PasskeysSection twoFactorEnabled={false} />);
|
|
|
|
|
const visitor = userEvent.setup();
|
|
|
|
|
|
|
|
|
|
await visitor.type(await screen.findByLabelText('Название ключа'), 'Ноутбук');
|
|
|
|
|
await visitor.type(screen.getByLabelText('Ваш пароль'), 'super-secret-1');
|
|
|
|
|
await visitor.click(screen.getByRole('button', { name: 'Создать ключ' }));
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(findRequest(fetchMock, { url: 'register/finish', method: 'POST' })).toBeDefined();
|
|
|
|
|
});
|
|
|
|
|
const finish = findRequest(fetchMock, { url: 'register/finish', method: 'POST' });
|
|
|
|
|
const body = finish?.body as {
|
|
|
|
|
ceremony: string;
|
|
|
|
|
name: string;
|
|
|
|
|
step_up_password: string;
|
|
|
|
|
credential: { response: { attestationObject: string } };
|
|
|
|
|
};
|
|
|
|
|
expect(body.ceremony).toBe('ceremony-1');
|
|
|
|
|
expect(body.name).toBe('Ноутбук');
|
|
|
|
|
expect(body.step_up_password).toBe('super-secret-1');
|
|
|
|
|
expect(body.credential.response.attestationObject).toBe(base64URL('attestation'));
|
|
|
|
|
// Пароль уходит и на begin: сервер требует свежий step-up.
|
|
|
|
|
const begin = findRequest(fetchMock, { url: 'register/begin', method: 'POST' });
|
|
|
|
|
expect(begin?.body).toEqual({ password: 'super-secret-1' });
|
|
|
|
|
expect(await screen.findByText('Ключ «Ноутбук» добавлен.')).toBeVisible();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('отмена создания ключа показывается человеческим текстом', async () => {
|
|
|
|
|
stubCredentials({
|
|
|
|
|
create: () => Promise.reject(Object.assign(new Error('x'), { name: 'NotAllowedError' })),
|
|
|
|
|
});
|
|
|
|
|
installFetch([
|
|
|
|
|
{
|
|
|
|
|
match: '/api/v1/auth/passkeys/register/begin',
|
|
|
|
|
method: 'POST',
|
|
|
|
|
response: () =>
|
|
|
|
|
json({
|
|
|
|
|
options: {
|
|
|
|
|
publicKey: {
|
|
|
|
|
challenge: base64URL('challenge'),
|
|
|
|
|
rp: { id: 'gl.mhspx.su', name: 'glchat' },
|
|
|
|
|
user: { id: base64URL('handle'), name: 'alice', displayName: 'Alice' },
|
|
|
|
|
pubKeyCredParams: [{ type: 'public-key', alg: -7 }],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
ceremony: 'ceremony-1',
|
|
|
|
|
expires_in_sec: 300,
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
{ match: '/api/v1/auth/passkeys', response: () => json({ passkeys: [], supported: true }) },
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
renderSection(<PasskeysSection twoFactorEnabled={false} />);
|
|
|
|
|
const visitor = userEvent.setup();
|
|
|
|
|
await visitor.type(await screen.findByLabelText('Название ключа'), 'Ключ');
|
|
|
|
|
await visitor.type(screen.getByLabelText('Ваш пароль'), 'super-secret-1');
|
|
|
|
|
await visitor.click(screen.getByRole('button', { name: 'Создать ключ' }));
|
|
|
|
|
|
|
|
|
|
expect(await screen.findByTestId('passkeys-error')).toHaveTextContent(
|
|
|
|
|
'Создание ключа отменено',
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('серверная ошибка step-up показывается как есть', async () => {
|
|
|
|
|
stubCredentials({ create: () => Promise.resolve(fakeCredential()) });
|
|
|
|
|
installFetch([
|
|
|
|
|
{
|
|
|
|
|
match: '/api/v1/auth/passkeys/register/begin',
|
|
|
|
|
method: 'POST',
|
|
|
|
|
response: () => apiError('auth.step_up_required', 403),
|
|
|
|
|
},
|
|
|
|
|
{ match: '/api/v1/auth/passkeys', response: () => json({ passkeys: [], supported: true }) },
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
renderSection(<PasskeysSection twoFactorEnabled={false} />);
|
|
|
|
|
const visitor = userEvent.setup();
|
|
|
|
|
await visitor.type(await screen.findByLabelText('Название ключа'), 'Ключ');
|
|
|
|
|
await visitor.type(screen.getByLabelText('Ваш пароль'), 'wrong');
|
|
|
|
|
await visitor.click(screen.getByRole('button', { name: 'Создать ключ' }));
|
|
|
|
|
|
|
|
|
|
expect(await screen.findByText(/Подтвердите личность/)).toBeVisible();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const instanceOpen = {
|
|
|
|
|
name: 'glchat-test',
|
|
|
|
|
version: 'v0.1.0-test',
|
|
|
|
|
registration_enabled: true,
|
|
|
|
|
allow_guild_creation: true,
|
|
|
|
|
max_guilds_per_user: 5,
|
|
|
|
|
max_members_per_guild: 100,
|
|
|
|
|
max_message_length: 2000,
|
|
|
|
|
main_guild_id: 'g-main',
|
|
|
|
|
user_count: 1,
|
|
|
|
|
guild_count: 1,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const metaWithPasskeys = {
|
|
|
|
|
name: 'glchat-test',
|
|
|
|
|
version: 'v0.1.0-test',
|
|
|
|
|
commit: 'deadbee',
|
|
|
|
|
build_date: '2026-09-26T00:00:00Z',
|
|
|
|
|
api_version: 'v1',
|
|
|
|
|
base_url: 'https://gl.mhspx.su',
|
|
|
|
|
files_url: 'https://files.gl.mhspx.su',
|
|
|
|
|
gateway_url: 'wss://gl.mhspx.su/gateway',
|
|
|
|
|
rtc_path: '/rtc',
|
|
|
|
|
max_upload_size: 26214400,
|
|
|
|
|
features: {
|
|
|
|
|
registration_enabled: true,
|
|
|
|
|
anti_bot_enabled: false,
|
|
|
|
|
voice_enabled: false,
|
|
|
|
|
web_push_enabled: false,
|
2026-09-26 16:15:54 +03:00
|
|
|
unfurl_enabled: false,
|
2026-09-26 15:13:24 +03:00
|
|
|
oauth_enabled: true,
|
2026-09-26 15:12:43 +03:00
|
|
|
passkeys_enabled: true,
|
2026-09-26 15:13:24 +03:00
|
|
|
oauth_providers: ['github'],
|
2026-09-26 15:12:43 +03:00
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-26 15:13:24 +03:00
|
|
|
describe('страница входа: ключи и внешние провайдеры', () => {
|
2026-09-26 15:12:43 +03:00
|
|
|
it('входит по ключу и переводит в приложение', async () => {
|
|
|
|
|
const assertion = {
|
|
|
|
|
id: 'credential-id',
|
|
|
|
|
rawId: new TextEncoder().encode('credential-id').buffer,
|
|
|
|
|
type: 'public-key',
|
|
|
|
|
authenticatorAttachment: null,
|
|
|
|
|
getClientExtensionResults: () => ({}),
|
|
|
|
|
response: {
|
|
|
|
|
clientDataJSON: new TextEncoder().encode('{}').buffer,
|
|
|
|
|
authenticatorData: new TextEncoder().encode('auth-data').buffer,
|
|
|
|
|
signature: new TextEncoder().encode('signature').buffer,
|
|
|
|
|
userHandle: null,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
stubCredentials({ get: () => Promise.resolve(assertion) });
|
|
|
|
|
|
|
|
|
|
const user = {
|
|
|
|
|
id: 'user-1',
|
|
|
|
|
username: 'alice',
|
|
|
|
|
display_name: 'Alice',
|
|
|
|
|
onboarding_completed: true,
|
|
|
|
|
totp_enabled: false,
|
|
|
|
|
badges: [],
|
|
|
|
|
};
|
|
|
|
|
const fetchMock = installFetch([
|
|
|
|
|
{ match: '/api/v1/meta', response: () => json(metaWithPasskeys) },
|
|
|
|
|
{ match: '/api/v1/instance', response: () => json({ instance: instanceOpen }) },
|
|
|
|
|
{
|
|
|
|
|
match: '/api/v1/auth/passkeys/login/begin',
|
|
|
|
|
method: 'POST',
|
|
|
|
|
response: () =>
|
|
|
|
|
json({
|
|
|
|
|
options: { publicKey: { challenge: base64URL('challenge') } },
|
|
|
|
|
ceremony: 'ceremony-login',
|
|
|
|
|
expires_in_sec: 300,
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
match: '/api/v1/auth/passkeys/login/finish',
|
|
|
|
|
method: 'POST',
|
|
|
|
|
response: () => json({ user }),
|
|
|
|
|
},
|
2026-09-26 15:13:24 +03:00
|
|
|
{
|
|
|
|
|
match: '/api/v1/auth/oauth/providers',
|
|
|
|
|
response: () =>
|
|
|
|
|
json({ providers: [{ id: 'github', name: 'GitHub' }], redirect_url: 'https://x' }),
|
|
|
|
|
},
|
2026-09-26 15:12:43 +03:00
|
|
|
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
|
|
|
|
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const { router } = renderApp('/login');
|
|
|
|
|
const visitor = userEvent.setup();
|
|
|
|
|
|
2026-09-26 15:13:24 +03:00
|
|
|
// Кнопка внешнего провайдера нарисована по списку с сервера.
|
|
|
|
|
expect(await screen.findByTestId('oauth-github')).toBeVisible();
|
2026-09-26 15:12:43 +03:00
|
|
|
await visitor.click(await screen.findByTestId('passkey-login'));
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(router.state.location.pathname).toBe('/app/empty');
|
|
|
|
|
});
|
|
|
|
|
const finish = findRequest(fetchMock, { url: 'passkeys/login/finish', method: 'POST' });
|
|
|
|
|
expect((finish?.body as { ceremony: string }).ceremony).toBe('ceremony-login');
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-26 15:13:24 +03:00
|
|
|
it('ошибка возврата от провайдера показывается на странице входа', async () => {
|
|
|
|
|
installFetch([
|
|
|
|
|
{
|
|
|
|
|
match: '/api/v1/meta',
|
|
|
|
|
response: () =>
|
|
|
|
|
json({
|
|
|
|
|
...metaWithPasskeys,
|
|
|
|
|
features: { ...metaWithPasskeys.features, passkeys_enabled: false },
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
{ match: '/api/v1/instance', response: () => json({ instance: instanceOpen }) },
|
|
|
|
|
{
|
|
|
|
|
match: '/api/v1/auth/oauth/providers',
|
|
|
|
|
response: () => json({ providers: [], redirect_url: 'https://x' }),
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
renderApp('/login?oauth_error=provider_not_configured');
|
|
|
|
|
|
|
|
|
|
expect(await screen.findByTestId('oauth-error')).toHaveTextContent(
|
|
|
|
|
'Этот способ входа выключен на инстансе',
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-26 15:12:43 +03:00
|
|
|
it('без поддержки браузера кнопка входа по ключу не показывается', async () => {
|
|
|
|
|
vi.stubGlobal('PublicKeyCredential', undefined);
|
|
|
|
|
installFetch([
|
|
|
|
|
{ match: '/api/v1/meta', response: () => json(metaWithPasskeys) },
|
|
|
|
|
{ match: '/api/v1/instance', response: () => json({ instance: instanceOpen }) },
|
2026-09-26 15:13:24 +03:00
|
|
|
{
|
|
|
|
|
match: '/api/v1/auth/oauth/providers',
|
|
|
|
|
response: () => json({ providers: [], redirect_url: 'https://x' }),
|
|
|
|
|
},
|
2026-09-26 15:12:43 +03:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
renderApp('/login');
|
|
|
|
|
|
|
|
|
|
await screen.findByLabelText('Почта');
|
|
|
|
|
expect(screen.queryByTestId('passkey-login')).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
});
|