diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 1e7652b4..bc425d94 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -226,4 +226,4 @@ REST запрос → ожидание → подтверждённый output / | Пользователи | [Профиль](ux-ui/screens/users-profile-desktop.png), [ошибка](ux-ui/screens/users-error-desktop.png) | [390](ux-ui/screens/users-profile-mobile.png), [320](ux-ui/screens/users-profile-narrow.png) | | Контейнеры | [Обзор](ux-ui/screens/containers-overview-desktop.png), [снимок](ux-ui/screens/containers-export-desktop.png) | [390](ux-ui/screens/containers-export-mobile.png), [320](ux-ui/screens/containers-export-narrow.png) | -Актуальная точка продолжения: 11А.21.2 — отменяемый polling с проверкой цели, затем checkpoint выпуска. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.3; таблица требований — [этап 11А](ux-ui-stage-11a.md). Сертификаты и весь 11А ещё не приняты. +Актуальная точка продолжения: 11А.21.3 — повторяемое создание issue с requestId, затем frontend checkpoint. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.3; таблица требований — [этап 11А](ux-ui-stage-11a.md). Сертификаты и весь 11А ещё не приняты. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 59a8985b..05a1eda0 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -27,7 +27,7 @@ | 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 | | 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 | | 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 | -| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.21.1; пользователи/контейнеры приняты локально, сертификаты: state/URL/API/renew/ACME готовы; далее 11А.21.2 | +| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.21.2; пользователи/контейнеры приняты локально, сертификаты: state/URL/API/renew/ACME готовы; далее 11А.21.3 | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,13 @@ +## Блок 11А.21.2 — отменяемое наблюдение issue задачи (2026-10-01) + +- pollCertificateIssue проверяет jobId, сервер, имя и домены перед наблюдением результата. queued/running не считаются успехом; done требует шага done. failed возвращает реальную ошибку задачи. +- Caller abort отменяет задержку и блокирует позднее наблюдение; polling ограничен 120 чтениями и сроком 5 минут, каждое HTTP чтение имеет свой срок. Ошибка GET/истечение наблюдения не объявляются failed задачей и не запускают новый POST. +- 5 fault unit и TypeScript/ESLint прошли. Helper пока не подключён к форме. +- Следующий блок 11А.21.3: повторяемое создание issue с устойчивым requestId на backend, затем frontend checkpoint и возобновление наблюдения. Это необходимо, чтобы потеря POST ответа не провоцировала второй выпуск. + ## Блок 11А.21.1 — совместимость issue DTO (2026-10-01) - Реальный backend issue DTO теперь преобразует числовой PostgreSQL serverId в строковый ID контракта; результат проверяется схемой вместо приведения step через as. JSON/JSONB имена доменов поддержаны, malformed значения отклоняются. diff --git a/docs/ux-ui-stage-11a.md b/docs/ux-ui-stage-11a.md index aa8444e5..b03cb353 100644 --- a/docs/ux-ui-stage-11a.md +++ b/docs/ux-ui-stage-11a.md @@ -249,3 +249,10 @@ - В enum шага добавлен failed, который уже записывает runner. Ошибка задачи и finishedAt сохраняются, а не теряются при frontend strict parse. Сетевой runner/порядок операций и правила прав не менялись. - 4 DTO unit, backend test:certificates/build, профильный ESLint, повторные 10 frontend API/snapshot unit и frontend TypeScript прошли. - Далее 11А.21.2: poll helper с отменой и проверкой ожидаемого server/name/domains, затем сохранённый checkpoint на странице и live HTTP fault тесты повторного нажатия/ошибки/смены источника. + +## Блок 11А.21.2 — отменяемое наблюдение issue задачи (2026-10-01) + +- pollCertificateIssue проверяет jobId, сервер, имя и домены перед наблюдением результата. queued/running не считаются успехом; done требует шага done. failed возвращает реальную ошибку задачи. +- Caller abort отменяет задержку и блокирует позднее наблюдение; polling ограничен 120 чтениями и сроком 5 минут, каждое HTTP чтение имеет свой срок. Ошибка GET/истечение наблюдения не объявляются failed задачей и не запускают новый POST. +- 5 fault unit и TypeScript/ESLint прошли. Helper пока не подключён к форме. +- Следующий блок 11А.21.3: повторяемое создание issue с устойчивым requestId на backend, затем frontend checkpoint и возобновление наблюдения. Это необходимо, чтобы потеря POST ответа не провоцировала второй выпуск. diff --git a/lib/certificate-issue-poll.test.ts b/lib/certificate-issue-poll.test.ts new file mode 100644 index 00000000..5920c5b6 --- /dev/null +++ b/lib/certificate-issue-poll.test.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import type { CertificateIssueJob } from "@mmapp/contracts/certificates" +import { pollCertificateIssue, waitForCertificatePoll } from "./certificate-issue-poll" +const target = { serverId: "1", certName: "cert", domainNames: ["test.example"] } +const queued: CertificateIssueJob = { id: "job-1", ...target, status: "queued", step: "queued", requestedAt: "2026-10-01T00:00:00Z" } +const signal = () => new AbortController().signal + +test("queued/running are observations; only confirmed terminal job completes", async () => { + let reads = 0; const observed: string[] = [] + const result = await pollCertificateIssue("", "job-1", target, { signal: signal(), wait: async () => {}, readJob: async () => ++reads === 1 ? queued : reads === 2 ? { ...queued, status: "running", step: "import" } : { ...queued, status: "done", step: "done" }, onJob: job => observed.push(job.status) }) + assert.equal(result.status, "done"); assert.deepEqual(observed, ["queued", "running", "done"]) +}) +test("failed job preserves server error without resubmission", async () => { + const result = await pollCertificateIssue("", "job-1", target, { signal: signal(), readJob: async () => ({ ...queued, status: "failed", step: "failed", error: "Import failed" }) }) + assert.equal(result.status, "failed"); assert.equal(result.error, "Import failed") +}) +test("foreign target or inconsistent done cannot invoke onJob or claim success", async () => { + for (const patch of [{ id: "other" }, { serverId: "2" }, { certName: "other" }, { domainNames: ["other.example"] }, { status: "done", step: "import" }]) { + let observations = 0 + await assert.rejects(() => pollCertificateIssue("", "job-1", target, { signal: signal(), readJob: async () => ({ ...queued, ...patch } as CertificateIssueJob), onJob: () => { observations++ } })) + assert.equal(observations, 0) + } +}) +test("read failure and exhausted observation budget do not become failed server jobs", async () => { + await assert.rejects(() => pollCertificateIssue("", "job-1", target, { signal: signal(), readJob: async () => { throw new Error("HTTP failure") } }), /HTTP failure/) + let reads = 0 + await assert.rejects(() => pollCertificateIssue("", "job-1", target, { signal: signal(), attempts: 2, wait: async () => {}, readJob: async () => { reads++; return queued } }), /может продолжаться/) + assert.equal(reads, 2) +}) +test("caller cancellation interrupts delay and suppresses late observation", async () => { + const abort = new AbortController(); const waiting = waitForCertificatePoll(60_000, abort.signal); abort.abort(new Error("cancel")); await assert.rejects(() => waiting, /cancel/) + const late = new AbortController(); let observed = 0 + await assert.rejects(() => pollCertificateIssue("", "job-1", target, { signal: late.signal, readJob: async () => { late.abort(new Error("source changed")); return { ...queued, status: "done", step: "done" } }, onJob: () => { observed++ } }), /source changed/) + assert.equal(observed, 0) +}) diff --git a/lib/certificate-issue-poll.ts b/lib/certificate-issue-poll.ts new file mode 100644 index 00000000..0a82b598 --- /dev/null +++ b/lib/certificate-issue-poll.ts @@ -0,0 +1,36 @@ +import type { CertificateIssueJob, CertificateIssueRequest } from "@mmapp/contracts/certificates" +import { getCertificateIssueJob } from "@/shared/api/certificates" + +export function waitForCertificatePoll(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal.aborted) { reject(signal.reason); return } + const abort = () => { clearTimeout(timer); signal.removeEventListener("abort", abort); reject(signal.reason) } + const timer = setTimeout(() => { signal.removeEventListener("abort", abort); resolve() }, ms) + signal.addEventListener("abort", abort, { once: true }) + }) +} + +export async function pollCertificateIssue(baseUrl: string, jobId: string, target: CertificateIssueRequest, options: { + signal: AbortSignal + onJob?: (job: CertificateIssueJob) => void + readJob?: typeof getCertificateIssueJob + wait?: typeof waitForCertificatePoll + attempts?: number + intervalMs?: number +}) { + const signal = AbortSignal.any([options.signal, AbortSignal.timeout(300_000)]) + const readJob = options.readJob ?? getCertificateIssueJob + const wait = options.wait ?? waitForCertificatePoll + const attempts = options.attempts ?? 120 + for (let index = 0; index < attempts; index++) { + signal.throwIfAborted() + const job = await readJob(baseUrl, jobId, { signal }) + signal.throwIfAborted() + if (job.id !== jobId || job.serverId !== String(target.serverId) || job.certName !== target.certName || JSON.stringify(job.domainNames) !== JSON.stringify(target.domainNames)) throw new Error("Ответ задачи не соответствует выбранному серверу, сертификату или доменам") + if (job.status === "done" && job.step !== "done") throw new Error("Завершение задачи не подтверждено её шагом") + options.onJob?.(job) + if (job.status === "done" || job.status === "failed") return job + if (index + 1 < attempts) await wait(options.intervalMs ?? 2000, signal) + } + throw new Error("Время наблюдения истекло. Задача может продолжаться на сервере; повторите проверку этой задачи.") +}