fix(ui): восстанавливать задачу выпуска без повторной операции

This commit is contained in:
Denozordec committed 2026-10-01 14:29:56 +07:00
1 parent 97526bf06f
commit f5a71728ac
10 files changed
+322 -81

No files matched your search

+29 -1
View File
@@ -1,6 +1,7 @@
import {test,expect} from "@playwright/test"
import {certificate as container,server,certificateState as state} from "./fixtures/certificates"
const headers={"access-control-allow-origin":"*","access-control-allow-methods":"GET,POST,OPTIONS","access-control-allow-headers":"Content-Type,Authorization"}
import {openIssue,issueJob} from "./fixtures/certificate-issue"
const headers={"access-control-allow-origin":"*","access-control-allow-methods":"GET,POST,PUT,OPTIONS","access-control-allow-headers":"Content-Type,Authorization"}
for(const change of ["backend","mode"])for(const fails of [false,true])test("Сертификаты: позднее чтение/"+change+"/"+fails,async({page})=>{
let started=false,completed=false,release!:()=>void;const gate=new Promise<void>(r=>release=r)
await page.route("**/health",r=>r.fulfill({headers,json:{ok:true}}));await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({headers,json:p==="/api/auth/config"?{required:false}:p==="/api/servers"?[server]:p==="/api/certificates/state"?state():{directoryUrl:"https://acme.example.test/directory",tokenConfigured:false,enabled:false,intervalSec:300,renewBeforeDays:30}})})
@@ -51,3 +52,30 @@ for (const fails of [false, true]) test("Сертификаты: позднее
await expect(directory).toHaveValue("https://acme.example.test/directory"); await expect(page.getByText("СТАРОЕ СОХРАНЕНИЕ ACME ОТКАЗ", { exact: true })).toHaveCount(0)
await expect(page.getByText("Настройки ACME сохранены", { exact: true })).toHaveCount(0)
})
for (const change of ["backend", "mode"]) for (const stage of ["POST", "poll"]) for (const fails of [false, true]) test("Сертификаты: поздний выпуск/" + change + "/" + stage + "/" + fails, async ({ page }) => {
let request: Record<string, unknown> = {}, started = false, completed = false, release!: () => void; const gate = new Promise<void>(r => release = r)
await page.route("**/health", r => r.fulfill({ headers, json: { ok: true } }))
await page.route("**/api/**", r => {
const path = new URL(r.request().url()).pathname
return r.fulfill({ headers, json: path === "/api/auth/config" ? { required: false } : path === "/api/servers" ? [server] : path === "/api/certificates/state" ? state() : path === "/api/certificates/renew-settings" ? { enabled: true, intervalSec: 21600, renewBeforeDays: 30 } : { directoryUrl: "https://acme.example.test/directory", tokenConfigured: true } })
})
await page.route("**/api/certificates/issue", async r => {
request = r.request().postDataJSON()
if (stage !== "POST") return r.fulfill({ headers, json: { jobId: request.requestId } })
started = true; await gate
try { await r.fulfill(fails ? { headers, status: 503, json: { error: "СТАРЫЙ ВЫПУСК ОТКАЗ" } } : { headers, json: { jobId: request.requestId } }) } catch {} finally { completed = true }
})
await page.route("**/api/certificates/issue/*", async r => {
if (stage !== "poll") return r.fulfill({ headers, json: issueJob(request) })
started = true; await gate
try { await r.fulfill(fails ? { headers, status: 503, json: { error: "СТАРЫЙ ВЫПУСК ОТКАЗ" } } : { headers, json: issueJob(request) }) } catch {} finally { completed = true }
})
const dialog = await openIssue(page); await dialog.getByRole("button", { name: "Выпустить", exact: true }).click(); await expect.poll(() => started).toBe(true)
await page.goto("/settings")
if (change === "backend") { const input = page.getByPlaceholder("http://localhost:8000"); await input.fill("http://localhost:8101"); await input.press("Enter") } else await page.getByRole("button", { name: "Моковые", exact: true }).click()
await page.goto("/certificates"); release(); await expect.poll(() => completed).toBe(true)
await expect(page.getByRole("button", { name: "Задача выпуска", exact: true })).toHaveCount(0)
await expect(page.getByText("API подтвердил завершение задачи выпуска", { exact: true })).toHaveCount(0)
await expect(page.getByText(/СТАРЫЙ ВЫПУСК ОТКАЗ/)).toHaveCount(0)
})
+63
View File
@@ -0,0 +1,63 @@
import { test, expect } from "@playwright/test"
import { certificateRoutes, issueJob, openIssue } from "./fixtures/certificate-issue"
test.beforeEach(async ({ page }) => certificateRoutes(page))
test("Выпуск: успех и отказ последующего каталога разделены", async ({ page }) => {
let request: Record<string, unknown> = {}; let writes = 0
await page.route("**/api/certificates/issue", r => { writes++; request = r.request().postDataJSON(); return r.fulfill({ json: { jobId: request.requestId } }) })
await page.route("**/api/certificates/issue/*", r => r.fulfill({ json: issueJob(request) }))
await page.route("**/api/certificates/state", r => writes ? r.fulfill({ status: 503, json: { error: "Повторное чтение отказало" } }) : r.fallback())
const dialog = await openIssue(page); await dialog.getByRole("button", { name: "Выпустить", exact: true }).click()
await expect(dialog.getByRole("button", { name: "Задача завершена", exact: true })).toBeDisabled()
await expect(page.getByText("API подтвердил завершение задачи выпуска", { exact: true })).toBeVisible()
await dialog.getByRole("button", { name: "Закрыть", exact: true }).last().click()
await expect(page.getByRole("alert", { name: "Каталог сертификатов", exact: true })).toContainText("Последние данные сохранены")
expect(writes).toBe(1)
})
test("Выпуск: GET ошибка и reload продолжают ту же задачу без POST", async ({ page }) => {
let request: Record<string, unknown> = {}, fail = true, writes = 0
await page.route("**/api/certificates/issue", r => { writes++; request = r.request().postDataJSON(); return r.fulfill({ json: { jobId: request.requestId } }) })
await page.route("**/api/certificates/issue/*", r => r.fulfill(fail ? { status: 503, json: { error: "Опрос отказал" } } : { json: issueJob(request) }))
let dialog = await openIssue(page); await dialog.getByRole("button", { name: "Выпустить", exact: true }).click()
await expect(dialog.getByRole("alert", { name: "Выпуск сертификата", exact: true })).toContainText("Продолжите проверку той же задачи")
await page.reload(); await page.getByRole("button", { name: "Задача выпуска", exact: true }).click()
dialog = page.getByRole("dialog", { name: "Выпуск сертификата", exact: true }); fail = false
await dialog.getByRole("button", { name: "Продолжить проверку", exact: true }).click()
await expect(dialog.getByRole("button", { name: "Задача завершена", exact: true })).toBeDisabled(); expect(writes).toBe(1)
})
for (const accepted of [false, true]) test("Выпуск: потерянное подтверждение POST/accepted=" + accepted, async ({ page }) => {
const requests: Record<string, unknown>[] = []; let exists = false
await page.route("**/api/certificates/issue", r => { const request = r.request().postDataJSON(); requests.push(request); exists = accepted || requests.length > 1; return r.fulfill(requests.length === 1 ? { status: 503, json: { error: "Ответ потерян" } } : { json: { jobId: request.requestId } }) })
await page.route("**/api/certificates/issue/*", r => r.fulfill(exists ? { json: issueJob(requests[0]) } : { status: 404, json: { error: "Задача не найдена" } }))
const dialog = await openIssue(page); await dialog.getByRole("button", { name: "Выпустить", exact: true }).click()
await expect(dialog.getByRole("alert", { name: "Выпуск сертификата", exact: true })).toContainText("Ответ потерян")
await dialog.getByRole("button", { name: "Продолжить проверку", exact: true }).click()
await expect(dialog.getByRole("button", { name: "Задача завершена", exact: true })).toBeDisabled()
expect(requests.length).toBe(accepted ? 1 : 2); for (const request of requests) expect(request.requestId).toBe(requests[0].requestId)
})
test("Выпуск: реальный failed и новая форма не повторяют POST автоматически", async ({ page }) => {
let request: Record<string, unknown> = {}, writes = 0
await page.route("**/api/certificates/issue", r => { writes++; request = r.request().postDataJSON(); return r.fulfill({ json: { jobId: request.requestId } }) })
await page.route("**/api/certificates/issue/*", r => r.fulfill({ json: issueJob(request, { status: "failed", step: "failed", error: "Импорт отказал" }) }))
const dialog = await openIssue(page); await dialog.getByRole("button", { name: "Выпустить", exact: true }).click()
await expect(dialog.getByRole("alert", { name: "Выпуск сертификата", exact: true })).toContainText("Импорт отказал")
await expect(dialog.getByRole("button", { name: "Задача завершена", exact: true })).toBeDisabled()
await dialog.getByRole("button", { name: "Новый выпуск", exact: true }).click()
await expect(dialog.getByRole("textbox", { name: "Имя сертификата", exact: true })).toHaveValue(""); expect(writes).toBe(1)
})
test("Выпуск: чужая цель в done не подтверждает успех", async ({ page }) => {
let request: Record<string, unknown> = {}
await page.route("**/api/certificates/issue", r => { request = r.request().postDataJSON(); return r.fulfill({ json: { jobId: request.requestId } }) })
await page.route("**/api/certificates/issue/*", r => r.fulfill({ json: issueJob(request, { serverId: "2" }) }))
const dialog = await openIssue(page); await dialog.getByRole("button", { name: "Выпустить", exact: true }).click()
await expect(dialog.getByRole("alert", { name: "Выпуск сертификата", exact: true })).toContainText("не соответствует")
await expect(page.getByText("API подтвердил завершение задачи выпуска", { exact: true })).toHaveCount(0)
await expect(dialog.getByRole("button", { name: "Продолжить проверку", exact: true })).toBeEnabled()
})
test("Выпуск: отказ записи checkpoint не отправляет запрос", async ({ page }) => {
await page.addInitScript(() => { sessionStorage.setItem = () => { throw new Error("storage denied") } })
let writes = 0; await page.route("**/api/certificates/issue", r => { writes++; return r.fulfill({ json: {} }) })
const dialog = await openIssue(page); await dialog.getByRole("button", { name: "Выпустить", exact: true }).click()
await expect(dialog.getByRole("alert", { name: "Выпуск сертификата", exact: true })).toContainText("Не удалось сохранить задачу")
expect(writes).toBe(0)
})
+23
View File
@@ -0,0 +1,23 @@
import { expect, type Page } from "@playwright/test"
import { certificateState, server } from "./certificates"
export async function certificateRoutes(page: Page) {
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => {
const path = new URL(r.request().url()).pathname
return r.fulfill({ json: path === "/api/auth/config" ? { required: false } : path === "/api/servers" ? [server] : path === "/api/certificates/state" ? certificateState() : path === "/api/certificates/renew-settings" ? { enabled: false, intervalSec: 300, renewBeforeDays: 30 } : { directoryUrl: "https://acme.example.test/directory", tokenConfigured: true } })
})
}
export function issueJob(request: Record<string, unknown>, overrides: Record<string, unknown> = {}) {
return { id: request.requestId, serverId: String(request.serverId), certName: request.certName, domainNames: request.domainNames, status: "done", step: "done", requestedAt: "2026-10-01T00:00:00Z", finishedAt: "2026-10-01T00:01:00Z", ...overrides }
}
export async function openIssue(page: Page) {
await page.goto("/certificates"); const action = page.getByRole("button", { name: "Выпустить сертификат", exact: true }); await expect(action).toBeEnabled(); await action.click()
const dialog = page.getByRole("dialog", { name: "Выпуск сертификата", exact: true })
await dialog.getByRole("combobox", { name: "Сервер выпуска", exact: true }).selectOption("1")
await dialog.getByRole("textbox", { name: "Имя сертификата", exact: true }).fill("new-cert")
await dialog.getByRole("button", { name: "Далее", exact: true }).click()
await dialog.getByRole("textbox", { name: "Основной домен", exact: true }).fill("test.example")
await dialog.getByRole("button", { name: "Далее", exact: true }).click()
await dialog.getByRole("button", { name: "Далее", exact: true }).click()
return dialog
}