fix(ui): сохранять контекст экспорта сертификатов IPsec
This commit is contained in:
1 parent
86ecf764ff
commit
74e7cd257d
7 files changed
+151
-67
No files matched your search
+41
-41
@@ -173,6 +173,10 @@ export default function IpsecPage() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [certBusy, setCertBusy] = useState(false)
|
||||
const [certError, setCertError] = useState<string | null>(null)
|
||||
const certEpoch = useRef(0)
|
||||
const certPending = useRef(false)
|
||||
useEffect(() => () => { certEpoch.current += 1 }, [])
|
||||
|
||||
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
|
||||
const [workspaceTab, setWorkspaceTab] = useState<IpsecWorkspaceTab>("clients")
|
||||
@@ -393,6 +397,10 @@ export default function IpsecPage() {
|
||||
setEditing(null)
|
||||
await loadLive()
|
||||
if (res.bundle) {
|
||||
certEpoch.current += 1
|
||||
certPending.current = false
|
||||
setCertBusy(false)
|
||||
setCertError(null)
|
||||
setCertBundle(res.bundle)
|
||||
setCertClient(res.client ?? null)
|
||||
setCertByName(null)
|
||||
@@ -421,63 +429,51 @@ export default function IpsecPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const loadCert = async (request: () => Promise<IpsecCertBundle>) => {
|
||||
if (certPending.current) return
|
||||
certPending.current = true
|
||||
const epoch = ++certEpoch.current
|
||||
setCertBusy(true)
|
||||
setCertError(null)
|
||||
try {
|
||||
const bundle = await request()
|
||||
if (epoch === certEpoch.current) setCertBundle(bundle)
|
||||
} catch (error) {
|
||||
if (epoch === certEpoch.current) setCertError(error instanceof Error ? error.message : "Ошибка экспорта сертификата")
|
||||
} finally {
|
||||
if (epoch === certEpoch.current) { certPending.current = false; setCertBusy(false) }
|
||||
}
|
||||
}
|
||||
|
||||
const openCert = async (client: IpsecClientDto) => {
|
||||
const byName = client.kind === "cert" && Boolean(client.certName)
|
||||
certEpoch.current += 1
|
||||
certPending.current = false
|
||||
setCertClient(byName ? null : client)
|
||||
setCertByName(byName ? { serverId: client.serverId, name: client.certName! } : null)
|
||||
setCertBundle(null)
|
||||
setCertOpen(true)
|
||||
setCertBusy(true)
|
||||
try {
|
||||
const passphrase = `mm-${Math.random().toString(36).slice(2, 10)}`
|
||||
const bundle = byName
|
||||
? await exportIpsecCertByName(backendUrl, client.serverId, client.certName!, passphrase)
|
||||
: await exportIpsecUserCert(backendUrl, client.serverId, client.rosId, passphrase)
|
||||
setCertBundle(bundle)
|
||||
} catch (e) {
|
||||
toast.error("Ошибка экспорта сертификата", {
|
||||
description: e instanceof Error ? e.message : String(e),
|
||||
})
|
||||
setCertOpen(false)
|
||||
} finally {
|
||||
setCertBusy(false)
|
||||
}
|
||||
const passphrase = crypto.randomUUID()
|
||||
await loadCert(() => byName
|
||||
? exportIpsecCertByName(backendUrl, client.serverId, client.certName!, passphrase)
|
||||
: exportIpsecUserCert(backendUrl, client.serverId, client.rosId, passphrase))
|
||||
}
|
||||
|
||||
/** Экспорт .p12 существующего клиентского серта по имени (client1/anakondra и т.п.). */
|
||||
const openCertByName = async (serverId: string, cert: IpsecCertInfoDto) => {
|
||||
certEpoch.current += 1
|
||||
certPending.current = false
|
||||
setCertByName({ serverId, name: cert.name })
|
||||
setCertClient(null)
|
||||
setCertBundle(null)
|
||||
setCertOpen(true)
|
||||
setCertBusy(true)
|
||||
try {
|
||||
const passphrase = `mm-${Math.random().toString(36).slice(2, 10)}`
|
||||
const bundle = await exportIpsecCertByName(backendUrl, serverId, cert.name, passphrase)
|
||||
setCertBundle(bundle)
|
||||
} catch (e) {
|
||||
toast.error("Ошибка экспорта сертификата", {
|
||||
description: e instanceof Error ? e.message : String(e),
|
||||
})
|
||||
setCertOpen(false)
|
||||
} finally {
|
||||
setCertBusy(false)
|
||||
}
|
||||
await loadCert(() => exportIpsecCertByName(backendUrl, serverId, cert.name, crypto.randomUUID()))
|
||||
}
|
||||
|
||||
const reexportCert = async (passphrase: string) => {
|
||||
if (!certClient && !certByName) return
|
||||
setCertBusy(true)
|
||||
try {
|
||||
const bundle = certByName
|
||||
? await exportIpsecCertByName(backendUrl, certByName.serverId, certByName.name, passphrase)
|
||||
: await exportIpsecUserCert(backendUrl, certClient!.serverId, certClient!.rosId, passphrase)
|
||||
setCertBundle(bundle)
|
||||
} catch (e) {
|
||||
toast.error("Ошибка экспорта", { description: e instanceof Error ? e.message : String(e) })
|
||||
} finally {
|
||||
setCertBusy(false)
|
||||
}
|
||||
await loadCert(() => certByName
|
||||
? exportIpsecCertByName(backendUrl, certByName.serverId, certByName.name, passphrase)
|
||||
: exportIpsecUserCert(backendUrl, certClient!.serverId, certClient!.rosId, passphrase))
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
@@ -850,7 +846,11 @@ export default function IpsecPage() {
|
||||
|
||||
<IpsecCertSheet
|
||||
open={certOpen}
|
||||
onOpenChange={(v) => { setCertOpen(v); if (!v) setCertByName(null) }}
|
||||
onOpenChange={(v) => {
|
||||
if (!v) { certEpoch.current += 1; certPending.current = false; setCertBusy(false); setCertBundle(null); setCertClient(null); setCertByName(null) }
|
||||
setCertOpen(v)
|
||||
}}
|
||||
error={certError}
|
||||
bundle={certBundle}
|
||||
busy={certBusy}
|
||||
onReexport={reexportCert}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
|
||||
import { IPSEC_MIN_PASSPHRASE, type IpsecCertBundle } from "@mmapp/contracts/ipsec"
|
||||
import { FormField, SectionTitle } from "@/components/form-kit"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -46,27 +46,19 @@ function IpsecCertSheet({
|
||||
onOpenChange,
|
||||
bundle,
|
||||
busy,
|
||||
error,
|
||||
onReexport,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (v: boolean) => void
|
||||
bundle: IpsecCertBundle | null
|
||||
busy?: boolean
|
||||
error?: string | null
|
||||
/** Перекачка с новой passphrase (серийник ключа остаётся на роутере). */
|
||||
onReexport?: (passphrase: string) => void | Promise<void>
|
||||
}) {
|
||||
const [passphrase, setPassphrase] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const initial = bundle?.passphrase ?? ""
|
||||
queueMicrotask(() => setPassphrase(initial))
|
||||
}, [open, bundle])
|
||||
|
||||
const canDownload = useMemo(
|
||||
() => Boolean(bundle && passphrase.trim().length >= IPSEC_MIN_PASSPHRASE),
|
||||
[bundle, passphrase],
|
||||
)
|
||||
const passphrase = bundle?.passphrase ?? ""
|
||||
const canDownload = Boolean(bundle && !busy && passphrase.trim().length >= IPSEC_MIN_PASSPHRASE)
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
@@ -79,10 +71,10 @@ function IpsecCertSheet({
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 min-w-0 overflow-y-auto overflow-x-hidden px-6 py-5 flex flex-col gap-5">
|
||||
{busy && <p role="status" className="text-sm text-muted-foreground">Загрузка сертификата…</p>}
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||||
{!bundle ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Бандл сертификата пуст — перезапустите экспорт с новой парольной фразой.
|
||||
</p>
|
||||
!busy && onReexport ? <Button variant="outline" onClick={() => void onReexport(randomPassphrase())}>Повторить экспорт</Button> : null
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -100,17 +92,18 @@ function IpsecCertSheet({
|
||||
<Input
|
||||
className="font-mono"
|
||||
value={passphrase}
|
||||
onChange={(e) => setPassphrase(e.target.value)}
|
||||
readOnly
|
||||
aria-label="Пароль архива .p12"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
title="Сгенерировать и перекачать"
|
||||
disabled={busy}
|
||||
aria-label="Сгенерировать и перекачать"
|
||||
disabled={busy || !onReexport}
|
||||
onClick={() => {
|
||||
const next = randomPassphrase()
|
||||
setPassphrase(next)
|
||||
if (onReexport) void onReexport(next)
|
||||
}}
|
||||
>
|
||||
|
||||
+2
-2
@@ -80,7 +80,7 @@ flowchart LR
|
||||
|
||||
## Текущая точка продолжения
|
||||
|
||||
По журналу этапы 0–8 завершены в границах локальной приёмки; внешние ограничения сохраняются. Этап 9 начат (блоки 9.1–9.15 — история, восстановление, инициализация и пользователи IPsec); этапы 10–12 не начаты. Этап 11 разделён: 11А — пользователи, контейнеры, сертификаты, резервирование; 11Б — оповещения, сбор данных, интеграции, релизы, настройки.
|
||||
По журналу этапы 0–8 завершены в границах локальной приёмки; внешние ограничения сохраняются. Этап 9 начат (блоки 9.1–9.16 — история, восстановление, формы и экспорт IPsec); этапы 10–12 не начаты. Этап 11 разделён: 11А — пользователи, контейнеры, сертификаты, резервирование; 11Б — оповещения, сбор данных, интеграции, релизы, настройки.
|
||||
|
||||
Подробные планы: [этап 6](ux-ui-stage-6.md), [этап 7](ux-ui-stage-7.md), [этап 8 и итоговая матрица](ux-ui-stage-8.md), [этап 9](ux-ui-stage-9.md).
|
||||
|
||||
@@ -88,7 +88,7 @@ flowchart LR
|
||||
|
||||
Ближайший оставшийся объём:
|
||||
|
||||
1. Этап 9: карта разделов и схема сохранены; удаление, импорт, создание, переключение, контекст экспорта и выбор пира WireGuard исправлены в 9.1–9.7; в 9.8 проверено подтверждение восстановления. В 9.9 исправлены разметка и сохранение подтверждения при ошибке у всех шести потребителей. В 9.10 исправлены ошибки и поздние ответы загрузки истории WireGuard. В 9.11 подтверждение восстановления проверено у остальных пяти потребителей. В 9.12 обработаны ошибка/повтор/поздние ответы истории IPsec. В 9.13 подключены ошибка/повтор/защита поздних ответов остальных четырёх разделов и общая валидация ревизий всех шести. В 9.14 исправлены подтверждение инициализации IPsec, сохранение формы и защита повторной отправки. В 9.15 проверены создание/редактирование пользователей IPsec и сохранение формы. Далее — получение и повторный экспорт сертификатов IPsec, затем GRE/VXLAN/Firewall/Filters.
|
||||
1. Этап 9: карта разделов и схема сохранены; удаление, импорт, создание, переключение, контекст экспорта и выбор пира WireGuard исправлены в 9.1–9.7; в 9.8 проверено подтверждение восстановления. В 9.9 исправлены разметка и сохранение подтверждения при ошибке у всех шести потребителей. В 9.10 исправлены ошибки и поздние ответы загрузки истории WireGuard. В 9.11 подтверждение восстановления проверено у остальных пяти потребителей. В 9.12 обработаны ошибка/повтор/поздние ответы истории IPsec. В 9.13 подключены ошибка/повтор/защита поздних ответов остальных четырёх разделов и общая валидация ревизий всех шести. В 9.14 исправлены подтверждение инициализации IPsec, сохранение формы и защита повторной отправки. В 9.15 проверены создание/редактирование пользователей IPsec и сохранение формы. В 9.16 проверены получение/повторный экспорт сертификатов, поздние ответы и соответствие отображаемого пароля архиву. Далее — удаление объектов IPsec и подтверждение API, затем GRE/VXLAN/Firewall/Filters.
|
||||
2. Сохранять контекст устройства, введённые значения при ошибке и подтверждать результат операций по API; локальные моки проверяют клиентские сценарии, реальные сетевые операции требуют тестового окружения.
|
||||
3. В этапе 12 выполнить полный аудит доступности (скринридер, контраст), производительности и сквозную регрессию. Ограничения этапа 8 — удалённые серверы месячного каталога, отсутствие экспорта и автоматического переподключения SSE — остаются явно зафиксированными, без заявления о реализации.
|
||||
|
||||
|
||||
+10
-1
@@ -25,12 +25,21 @@
|
||||
| 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже |
|
||||
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
|
||||
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
||||
| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.15 — история, восстановление, инициализация и пользователи IPsec |
|
||||
| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.16 — история, восстановление, формы и экспорт IPsec |
|
||||
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
|
||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||
|
||||
## Блок 9.16 — экспорт сертификатов IPsec (2026-09-29)
|
||||
|
||||
- Оба API экспорта проверяют схему бандла, непустые имя/файл/содержимое, синтаксис Base64 и наличие пароля архива. Это проверка транспортного ответа, не криптографическая проверка PKCS#12. Начальный пароль генерируется crypto.randomUUID вместо Math.random.
|
||||
- Общий обработчик связывает результат/ошибку/finally с открытием панели; закрытие, новое открытие и размонтирование инвалидируют старый ответ. Повтор защищён синхронным замком. Ошибка остаётся в панели с повтором; первичная загрузка объявляется через status. Физическая отмена запросов не добавлена.
|
||||
- Пароль загруженного архива доступен только для чтения: изменение требует нового экспорта. При ошибке повторного экспорта остаются прежний файл и его пароль; во время запроса скачивание заблокировано. Backend может вернуть эффективный пароль, поэтому показывается именно пароль из подтверждённого ответа.
|
||||
- Все 6 E2E прошли: два новых сценария экспорта (identity и имя сертификата), инициализация и три сценария пользователей. Проверены поздний успех закрытой панели, 403, неверный Base64, повторное нажатие, 500 при повторном экспорте и успешная смена пары файл/пароль. Lint изменённых файлов, TypeScript и diff-check чистые. Production build прошёл: 34/34 страницы.
|
||||
- Далее: удаление IPsec — confirmDelete и ответы удаления клиента/сервера/peer/сертификата, сохранение подтверждения при ошибке и повторные нажатия. Экспорт из формы создания получает бандл по прежней схеме создания; реальные сертификаты/RouterOS/JWT не проверены. Этап 9 открыт.
|
||||
- После тестов использовано 67% пятичасовой и 88% недельной квоты. Локальный коммит без push; чужие изменения сохранены.
|
||||
|
||||
## Блок 9.15 — создание и редактирование пользователей IPsec (2026-09-29)
|
||||
|
||||
- Создание проверяет ipsecUserCreatedSchema, принадлежность клиента серверу, имя и метод аутентификации. Пустой ответ, client=null и чужой сервер не считаются успехом; редактирование требует ok=true. Backend иногда возвращает client=null после применения: UI сообщает отсутствие подтверждения, а не утверждает откат операции.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Этап 9: VPN и сетевые операции
|
||||
|
||||
Статус: начат 2026-09-28, выполнены блоки 9.1–9.15. Основа — существующие ReUI PRO/base-nova, Frame, ServerTileRail, DataGrid и формы. Правила применения сетевой конфигурации не меняем.
|
||||
Статус: начат 2026-09-28, выполнены блоки 9.1–9.16. Основа — существующие ReUI PRO/base-nova, Frame, ServerTileRail, DataGrid и формы. Правила применения сетевой конфигурации не меняем.
|
||||
|
||||
## Схема и референсы принятого плана
|
||||
|
||||
@@ -101,3 +101,7 @@ IPsec/GRE/Firewall/Filters/рекурсивные маршруты требую
|
||||
## Блок 9.15 — пользователи IPsec (2026-09-29)
|
||||
|
||||
Создание проверяет схему клиента, сервер, имя и метод; редактирование требует ok=true. Форма сохраняет ввод и ошибку, блокирует повторы/закрытие/поля до ответа; добавлены доступные имена, сброс peer при смене сервера и проверка нового PSK. Все 7 E2E, lint и TypeScript прошли. Далее — загрузка/повторный экспорт сертификатов и контекст запросов. Реальные устройства и выпуск сертификатов не проверены; последний build — 9.13.
|
||||
|
||||
## Блок 9.16 — сертификаты IPsec (2026-09-29)
|
||||
|
||||
Экспорт по identity/имени проверяет бандл, пароль и Base64; закрытая панель не принимает поздние ответы. Ошибка сохраняет панель для повтора; прежний архив сохраняет свой пароль при ошибке нового экспорта. Пароль только для чтения, скачивание заблокировано при запросе. Все 6 E2E, lint и TypeScript прошли; build — в журнале. Далее — удаление объектов IPsec. Реальный PKCS#12/RouterOS не проверен, сетевой запрос после закрытия не отменяется.
|
||||
+16
-3
@@ -1,4 +1,4 @@
|
||||
import { ipsecServerSummaryDtoSchema, ipsecUserCreatedSchema } from "@mmapp/contracts/ipsec"
|
||||
import { ipsecServerSummaryDtoSchema, ipsecUserCreatedSchema, ipsecCertBundleSchema } from "@mmapp/contracts/ipsec"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
import type {
|
||||
IpsecCertBundle,
|
||||
@@ -97,17 +97,29 @@ export async function deleteIpsecUser(
|
||||
)
|
||||
}
|
||||
|
||||
function parseExportBundle(response: unknown): IpsecCertBundle {
|
||||
const result = ipsecCertBundleSchema.safeParse(response)
|
||||
if (!result.success || !result.data.filename.trim() || !result.data.user.trim() ||
|
||||
!result.data.passphrase || result.data.passphrase.length < 8 ||
|
||||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(result.data.contentB64) ||
|
||||
!result.data.contentB64) {
|
||||
throw new Error("Некорректный ответ экспорта сертификата")
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
|
||||
export async function exportIpsecUserCert(
|
||||
baseUrl: string,
|
||||
serverId: string,
|
||||
clientId: string,
|
||||
passphrase: string,
|
||||
): Promise<IpsecCertBundle> {
|
||||
return requestJson<IpsecCertBundle>(
|
||||
const response = await requestJson<unknown>(
|
||||
baseUrl,
|
||||
`/api/ipsec/users/${encodeURIComponent(serverId)}/${encodeURIComponent(clientId)}/cert`,
|
||||
{ method: "POST", body: JSON.stringify({ passphrase }) },
|
||||
)
|
||||
return parseExportBundle(response)
|
||||
}
|
||||
|
||||
export async function exportIpsecCertByName(
|
||||
@@ -116,11 +128,12 @@ export async function exportIpsecCertByName(
|
||||
name: string,
|
||||
passphrase: string,
|
||||
): Promise<IpsecCertBundle> {
|
||||
return requestJson<IpsecCertBundle>(
|
||||
const response = await requestJson<unknown>(
|
||||
baseUrl,
|
||||
`/api/ipsec/certs/${encodeURIComponent(serverId)}/export`,
|
||||
{ method: "POST", body: JSON.stringify({ name, passphrase }) },
|
||||
)
|
||||
return parseExportBundle(response)
|
||||
}
|
||||
|
||||
export async function restoreIpsecRevision(
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { test, expect } from "@playwright/test"
|
||||
|
||||
for (const byName of [false, true]) {
|
||||
test(`IPsec: экспорт ${byName ? "по имени" : "по identity"} сохраняет контекст и пароль архива`, async ({ page }) => {
|
||||
const client = { id: "1", rosId: "*P/1", serverId: "1", serverName: "Роутер", name: "alice", authMethod: "certificate", kind: "cert", certName: byName ? "alice-cert" : undefined, online: false, disabled: false, managed: true }
|
||||
let attempts = 0
|
||||
let release: (() => void) | undefined
|
||||
const gate = new Promise<void>(resolve => { release = resolve })
|
||||
let confirmedPassphrase = ""
|
||||
await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
|
||||
await page.route("**/api/**", async route => {
|
||||
const path = new URL(route.request().url()).pathname
|
||||
if (route.request().method() === "POST") {
|
||||
attempts++
|
||||
expect(decodeURIComponent(path)).toBe(byName ? "/api/ipsec/certs/1/export" : "/api/ipsec/users/1/*P/1/cert")
|
||||
const body = route.request().postDataJSON()
|
||||
if (byName) expect(body.name).toBe("alice-cert")
|
||||
expect(body.passphrase.length).toBeGreaterThanOrEqual(8)
|
||||
if (attempts === 1) { await gate; return route.fulfill({ json: { user: "stale", filename: "stale.p12", contentB64: "YQ==", passphrase: body.passphrase } }) }
|
||||
if (attempts === 2) return route.fulfill({ status: 403, json: { error: "Экспорт запрещён тест" } })
|
||||
if (attempts === 3) return route.fulfill({ json: { user: "alice", filename: "alice.p12", contentB64: "!invalid!", passphrase: body.passphrase } })
|
||||
if (attempts === 5) return route.fulfill({ status: 500, json: { error: "Повторный экспорт не выполнен" } })
|
||||
confirmedPassphrase = body.passphrase
|
||||
return route.fulfill({ json: { user: "alice", filename: "alice.p12", contentB64: "YQ==", passphrase: body.passphrase } })
|
||||
}
|
||||
return route.fulfill({ json: path === "/api/auth/config" ? { required: false }
|
||||
: path === "/api/ipsec" ? { servers: [], clients: [client] }
|
||||
: path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.1", enabled: true, type: "exit-node", country: "RU", status: "online" }] : {} })
|
||||
})
|
||||
await page.goto("/ipsec")
|
||||
const open = page.getByRole("button", { name: "Скачать сертификат", exact: true })
|
||||
await open.click()
|
||||
const sheet = page.getByRole("dialog")
|
||||
await expect(sheet.getByRole("status")).toContainText("Загрузка сертификата")
|
||||
await expect.poll(() => attempts).toBe(1)
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(sheet).toHaveCount(0)
|
||||
await open.click()
|
||||
await expect(sheet.getByRole("alert")).toContainText("Экспорт запрещён тест")
|
||||
const oldResponse = page.waitForResponse(r => r.request().method() === "POST")
|
||||
release!()
|
||||
await (await oldResponse).finished()
|
||||
await page.waitForTimeout(100)
|
||||
await expect(sheet.getByRole("alert")).toContainText("Экспорт запрещён тест")
|
||||
await expect(sheet.getByText("stale.p12", { exact: false })).toHaveCount(0)
|
||||
const retry = sheet.getByRole("button", { name: "Повторить экспорт" })
|
||||
await retry.evaluate(button => { (button as HTMLButtonElement).click(); (button as HTMLButtonElement).click() })
|
||||
await expect.poll(() => attempts).toBe(3)
|
||||
await expect(sheet.getByRole("alert")).toContainText("Некорректный ответ экспорта")
|
||||
await retry.click()
|
||||
const password = sheet.getByLabel("Пароль архива .p12")
|
||||
await expect(password).toHaveValue(confirmedPassphrase)
|
||||
await expect(password).toHaveAttribute("readonly", "")
|
||||
const original = confirmedPassphrase
|
||||
await sheet.getByRole("button", { name: "Сгенерировать и перекачать" }).click()
|
||||
await expect(sheet.getByRole("alert")).toContainText("Повторный экспорт не выполнен")
|
||||
await expect(password).toHaveValue(original)
|
||||
await expect(sheet.getByRole("button", { name: /alice.p12/ })).toBeEnabled()
|
||||
await sheet.getByRole("button", { name: "Сгенерировать и перекачать" }).click()
|
||||
await expect.poll(() => attempts).toBe(6)
|
||||
await expect(password).not.toHaveValue(original)
|
||||
await expect(password).toHaveValue(confirmedPassphrase)
|
||||
await expect(sheet.getByRole("alert")).toHaveCount(0)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user