fix(web): обратный поток из контура — vitest 3.1.4, форма dev-входа в любой сборке (#92)

- vitest зафиксирован точно на 3.1.4: в зеркале npm банка нет 3.2.x, lock-файл не в git,
  каретка давала бы разные версии снаружи и внутри контура;
- DevLogin больше не выбирает форму по NODE_ENV: платформа всегда собирает фронт в production,
  а контур работает на dev-заглушке аутентификации, переменные сборки в пайплайне задать нельзя.
  Форма входа по email показывается всегда; заглушка «Вход выполняется через РАИСА» — только после
  404 «Not Found» от бэкенда (маршрута /dev/token нет → dev-эмитент выключен). Признак вынесен
  в isDevIssuerDisabled и покрыт тестами: 404 «пользователь не найден» остаётся ошибкой в форме.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-09 20:29:39 +04:00
parent 71e55b19ca
commit d065fc31dd
4 changed files with 53 additions and 10 deletions

View File

@ -30,7 +30,7 @@
"daisyui": "^4.12.10",
"tailwindcss": "^3.4.13",
"typescript": "^5.7.3",
"vitest": "^3.2.7"
"vitest": "3.1.4"
},
"optionalDependencies": {
"@biomejs/cli-linux-x64-musl": "^1.9.4",

View File

@ -2,12 +2,14 @@ import { Alert, Button, Form, Input } from 'antd';
import { useState } from 'react';
import { api } from '../api/client';
import type { DevTokenResponse } from '../api/types';
import { isDevIssuerDisabled } from './devIssuer';
import { setToken } from './token';
type Props = { onLogin: () => void; error?: string | null };
// Вход по email для разработки: бэкенд выпускает токен только при AUTH_DEV_ISSUER=true и DEBUG=true.
function DevLoginForm({ onLogin, error }: Props) {
// Вход по email для разработки и контура без SSO: бэкенд выпускает токен только при AUTH_DEV_ISSUER=true и DEBUG=true.
// `onDisabled` — бэкенд ответил, что маршрута /dev/token нет (dev-эмитент выключен): вход возможен только через SSO.
function DevLoginForm({ onLogin, error, onDisabled }: Props & { onDisabled: () => void }) {
const [submitting, setSubmitting] = useState(false);
const [failure, setFailure] = useState<string | null>(null);
@ -19,6 +21,10 @@ function DevLoginForm({ onLogin, error }: Props) {
setToken(access_token);
onLogin();
} catch (e: unknown) {
if (isDevIssuerDisabled(e)) {
onDisabled();
return;
}
setFailure(e instanceof Error ? e.message : 'Не удалось выполнить вход');
} finally {
setSubmitting(false);
@ -41,8 +47,8 @@ function DevLoginForm({ onLogin, error }: Props) {
);
}
// Production: вход выполняет внешний SSO; если загрузка профиля не удалась, показываем причину и даём повторить запрос
function ProdNotice({ onLogin, error }: Props) {
// Dev-эмитент выключен: вход выполняет внешний SSO; если загрузка профиля не удалась, показываем причину и даём повторить запрос
function SsoNotice({ onLogin, error }: Props) {
return (
<div style={{ width: 360 }}>
<p>Вход выполняется через РАИСА</p>
@ -54,15 +60,22 @@ function ProdNotice({ onLogin, error }: Props) {
);
}
/**
* Какую форму показать, решает не режим сборки, а бэкенд во время выполнения: платформа всегда собирает фронт
* в production, при этом контур может работать на dev-заглушке аутентификации (переменные сборки в пайплайне
* задать нельзя). Поэтому форма входа по email показывается всегда, а заглушка про SSO — только после того,
* как бэкенд ответил, что маршрута /dev/token нет.
*/
export function DevLogin(props: Props) {
const [ssoOnly, setSsoOnly] = useState(false);
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100svh' }}>
{/* Литерал process.env.NODE_ENV rsbuild подставляет при сборке: в production форма вырезается из бандла целиком */}
{process.env.NODE_ENV !== 'production' ? (
<DevLoginForm {...props} />
{ssoOnly ? (
// TODO(OQ-54): параметры JWT РАИСА (издатель, claim с email) и сам сценарий входа через SSO
<SsoNotice {...props} />
) : (
// TODO(OQ): параметры JWT РАИСА (издатель, claim с email)
<ProdNotice {...props} />
<DevLoginForm {...props} onDisabled={() => setSsoOnly(true)} />
)}
</div>
);

View File

@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { ApiError } from '../api/client';
import { isDevIssuerDisabled } from './devIssuer';
describe('isDevIssuerDisabled', () => {
it('404 «Not Found» без кода — маршрута /dev/token нет, dev-эмитент выключен', () => {
expect(isDevIssuerDisabled(new ApiError(404, undefined, 'Not Found', { detail: 'Not Found' }))).toBe(true);
});
it('404 «пользователь не найден или отключён» — маршрут есть, это ошибка входа', () => {
expect(isDevIssuerDisabled(new ApiError(404, undefined, 'пользователь не найден или отключён'))).toBe(false);
});
it('другие статусы и не-ApiError не считаются выключенным эмитентом', () => {
expect(isDevIssuerDisabled(new ApiError(409, undefined, 'неоднозначная учётная запись'))).toBe(false);
expect(isDevIssuerDisabled(new ApiError(0, 'NETWORK', 'Нет связи с сервером'))).toBe(false);
expect(isDevIssuerDisabled(new Error('Not Found'))).toBe(false);
expect(isDevIssuerDisabled(undefined)).toBe(false);
});
});

10
web/src/auth/devIssuer.ts Normal file
View File

@ -0,0 +1,10 @@
import { ApiError } from '../api/client';
/**
* Маршрут `POST /dev/token` существует только при `DEBUG=true` и `AUTH_DEV_ISSUER=true` на бэкенде; иначе приложение
* отвечает 404 со строкой «Not Found» (а не доменной ошибкой с кодом). Это и есть признак «dev-эмитент выключен,
* вход только через SSO». 404 «пользователь не найден или отключён» приходит с того же статуса, но от самого маршрута.
*/
export function isDevIssuerDisabled(error: unknown): boolean {
return error instanceof ApiError && error.status === 404 && error.code === undefined && error.message === 'Not Found';
}