diff --git a/backend/package.json b/backend/package.json index a6d2470c..00cc60c0 100644 --- a/backend/package.json +++ b/backend/package.json @@ -30,7 +30,7 @@ "test:geoip": "tsx src/services/traffic-flow-geoip.test.ts", "test:ospf-optimizer": "tsx src/services/ospf-optimizer-identity.test.ts && tsx src/services/ospf-optimizer-metrics.test.ts && tsx src/services/ospf-optimizer-plan.test.ts && tsx src/routes/ospf-optimizer.test.ts", "test:containers": "tsx src/services/containers-state.test.ts && tsx src/routes/containers-state.test.ts", - "test:certificates": "tsx src/services/certificate-issue-dto.test.ts && tsx src/services/certificate-parse.test.ts && tsx src/services/certificates-state.test.ts && tsx src/routes/certificates-state.test.ts" + "test:certificates": "tsx src/services/certificate-issue-request.test.ts && tsx src/routes/certificates-issue.test.ts && tsx src/services/certificate-issue-dto.test.ts && tsx src/services/certificate-parse.test.ts && tsx src/services/certificates-state.test.ts && tsx src/routes/certificates-state.test.ts" }, "dependencies": { "@aws-sdk/client-s3": "^3.888.0", diff --git a/backend/src/routes/certificates-issue.test.ts b/backend/src/routes/certificates-issue.test.ts new file mode 100644 index 00000000..fd97c309 --- /dev/null +++ b/backend/src/routes/certificates-issue.test.ts @@ -0,0 +1,16 @@ +import assert from "node:assert/strict" +import Fastify from "fastify" +import { validatorCompiler, serializerCompiler } from "@fastify/type-provider-zod" +import { createCertificatesRoutes } from "./certificates.js" +import { acceptCertificateIssueRequest, type IssueRecordInput } from "../services/certificate-issue-request.js" +import { permissionForRequest } from "../lib/permissions.js" +const jobs = new Map(); let queued = 0 +const app = Fastify(); app.setValidatorCompiler(validatorCompiler); app.setSerializerCompiler(serializerCompiler) +await app.register(createCertificatesRoutes({ acceptIssue: input => acceptCertificateIssueRequest(input, { getServer: async () => ({}), getJob: async id => jobs.get(id) ?? null, createJob: async row => { if (jobs.has(row.id)) return false; jobs.set(row.id, row); return true }, queue: () => { queued++ } }) }), { prefix: "/api" }) +const body = { requestId: "11111111-1111-4111-8111-111111111111", serverId: "1", certName: " cert ", domainNames: [" test.example "] } +const post = (payload: unknown) => app.inject({ method: "POST", url: "/api/certificates/issue", payload: payload as Record }) +const first = await post(body); assert.equal(first.statusCode, 200); assert.deepEqual(first.json(), { jobId: body.requestId }); assert.equal((await post(body)).statusCode, 200); assert.equal(queued, 1); assert.equal(jobs.size, 1) +assert.equal((await post({ ...body, certName: "other" })).statusCode, 409) +for (const patch of [{ requestId: "bad" }, { serverId: "1junk" }, { serverId: 0 }, { serverId: 9007199254740992 }, { certName: " " }, { domainNames: [" "] }]) assert.equal((await post({ ...body, ...patch })).statusCode, 400) +assert.equal(permissionForRequest("POST", "/api/certificates/issue"), "mm:certificates:write") +await app.close(); console.log("Certificate issue HTTP: stable ack/retry, target conflict, strict input, unchanged RBAC") diff --git a/backend/src/routes/certificates.ts b/backend/src/routes/certificates.ts index 1c914ce0..24f3fb0b 100644 --- a/backend/src/routes/certificates.ts +++ b/backend/src/routes/certificates.ts @@ -1,4 +1,3 @@ -import { randomUUID } from "node:crypto" import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" import { certificateIssueRequestSchema, @@ -7,11 +6,12 @@ import { putCertificateRenewSettingsSchema, testAcmeCloudflareSettingsSchema, } from "@mmapp/contracts/certificates" +import { acceptCertificateIssueRequest, IssueRequestError } from "../services/certificate-issue-request.js" import { refreshScheduler } from "../services/scheduler.js" import { testCloudflareToken } from "../services/acme-cloudflare.js" import { queueCertificateIssueJob } from "../services/certificate-issue-runner.js" import { - createIssueJobRecord, + createIssueJobRecordIfAbsent, getAcmeCloudflareToken, getAcmeSettingsPublic, getCertificateRenewSettings, @@ -24,7 +24,7 @@ import { updateCertificateRenewSettings, } from "../services/certificates-service.js" -export function createCertificatesRoutes(deps: { getState?: typeof listCertificatesState } = {}): FastifyPluginAsyncZod { +export function createCertificatesRoutes(deps: { getState?: typeof listCertificatesState; acceptIssue?: (input: import("@mmapp/contracts/certificates").CertificateIssueRequest) => Promise<{ jobId: string }> } = {}): FastifyPluginAsyncZod { return async (app) => { app.get("/certificates/state", async (_req, reply) => { const parsed = certificatesStateSchema.safeParse(await (deps.getState ?? listCertificatesState)()) @@ -90,23 +90,15 @@ export function createCertificatesRoutes(deps: { getState?: typeof listCertifica if (!parsed.success) { return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) } - const serverId = String(parsed.data.serverId) - const server = await getServerRowByIdString(serverId) - if (!server) return reply.status(404).send({ error: "Сервер не найден" }) - - const jobId = randomUUID() - const trustStore = (parsed.data.trustStore ?? ["www", "api"]).join(",") - await createIssueJobRecord({ - id: jobId, - serverId, - certName: parsed.data.certName.trim(), - domainNames: parsed.data.domainNames.map((d) => d.trim()).filter(Boolean), - keyType: parsed.data.keyType ?? "rsa2048", - trustStore, - source: "manual", - }) - queueCertificateIssueJob(jobId) - return reply.send({ jobId }) + try { + const result = await (deps.acceptIssue ? deps.acceptIssue(parsed.data) : acceptCertificateIssueRequest(parsed.data, { + getServer: getServerRowByIdString, getJob: getIssueJobRecord, createJob: createIssueJobRecordIfAbsent, queue: queueCertificateIssueJob, + })) + return reply.send(result) + } catch (error) { + if (error instanceof IssueRequestError) return reply.status(error.status).send({ error: error.message }) + throw error + } }) app.get("/certificates/issue/:jobId", async (req, reply) => { diff --git a/backend/src/services/certificate-issue-request.test.ts b/backend/src/services/certificate-issue-request.test.ts new file mode 100644 index 00000000..9e9b0f3a --- /dev/null +++ b/backend/src/services/certificate-issue-request.test.ts @@ -0,0 +1,32 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { acceptCertificateIssueRequest, IssueRequestError, type IssueRecordInput } from "./certificate-issue-request.js" +const request = { requestId: "11111111-1111-4111-8111-111111111111", serverId: "1", certName: "cert", domainNames: ["test.example"] } +function repository() { + const jobs = new Map(); const queued: string[] = [] + return { jobs, queued, deps: { getServer: async () => ({}), getJob: async (id: string) => jobs.get(id) ?? null, createJob: async (row: IssueRecordInput) => { if (jobs.has(row.id)) return false; jobs.set(row.id, row); return true }, queue: (id: string) => { queued.push(id) } } } +} +test("lost acknowledgment retry returns same job without duplicate create or queue", async () => { + const repo = repository() + const first = await acceptCertificateIssueRequest(request, repo.deps) + const second = await acceptCertificateIssueRequest(request, repo.deps) + assert.deepEqual(first, second); assert.equal(repo.jobs.size, 1); assert.deepEqual(repo.queued, [request.requestId]) +}) +test("concurrent matching requests produce one job and one queue dispatch", async () => { + const repo = repository(); const results = await Promise.all([acceptCertificateIssueRequest(request, repo.deps), acceptCertificateIssueRequest(request, repo.deps)]) + assert.deepEqual(results[0], results[1]); assert.equal(repo.jobs.size, 1); assert.equal(repo.queued.length, 1) +}) +test("request ID collision with different target is conflict, never new operation", async () => { + const repo = repository(); await acceptCertificateIssueRequest(request, repo.deps) + for (const patch of [{ serverId: "2" }, { certName: "other" }, { domainNames: ["other.example"] }, { keyType: "ec256" as const }, { trustStore: ["api"] as const }]) { + await assert.rejects(() => acceptCertificateIssueRequest({ ...request, ...patch, ...(patch.trustStore ? { trustStore: [...patch.trustStore] } : {}) }, repo.deps), error => error instanceof IssueRequestError && error.status === 409) + } + assert.equal(repo.jobs.size, 1); assert.equal(repo.queued.length, 1) +}) +test("missing server is rejected; legacy requests without requestId remain independent", async () => { + const repo = repository(); await assert.rejects(() => acceptCertificateIssueRequest(request, { ...repo.deps, getServer: async () => null }), error => error instanceof IssueRequestError && error.status === 404) + assert.equal(repo.jobs.size, 0) + const { requestId: unused, ...legacy } = request; void unused + const first = await acceptCertificateIssueRequest(legacy, repo.deps), second = await acceptCertificateIssueRequest(legacy, repo.deps) + assert.notEqual(first.jobId, second.jobId); assert.equal(repo.jobs.size, 2) +}) diff --git a/backend/src/services/certificate-issue-request.ts b/backend/src/services/certificate-issue-request.ts new file mode 100644 index 00000000..2a3034c0 --- /dev/null +++ b/backend/src/services/certificate-issue-request.ts @@ -0,0 +1,32 @@ +import { randomUUID } from "node:crypto" +import type { CertificateIssueRequest } from "@mmapp/contracts/certificates" +import { parseJsonArray } from "../db/json.js" + +export type IssueRecordInput = { id: string; serverId: string; certName: string; domainNames: string[]; keyType: string; trustStore: string; source: "manual" } +type ExistingIssue = { serverId: number | string; certName: string; domainNames: unknown; keyType: string; trustStore: string; source: string } +export class IssueRequestError extends Error { + constructor(message: string, readonly status: number) { super(message) } +} + +export async function acceptCertificateIssueRequest(input: CertificateIssueRequest, deps: { + getServer: (id: string) => Promise + getJob: (id: string) => Promise + createJob: (row: IssueRecordInput) => Promise + queue: (id: string) => void +}) { + const id = input.requestId ?? randomUUID() + const row: IssueRecordInput = { id, serverId: String(input.serverId), certName: input.certName, domainNames: input.domainNames, keyType: input.keyType ?? "rsa2048", trustStore: (input.trustStore ?? ["www", "api"]).join(","), source: "manual" } + const matches = (existing: ExistingIssue) => String(existing.serverId) === row.serverId && existing.certName === row.certName && JSON.stringify(parseJsonArray(existing.domainNames)) === JSON.stringify(row.domainNames) && existing.keyType === row.keyType && existing.trustStore === row.trustStore && existing.source === "manual" + const previous = await deps.getJob(id) + if (previous) { + if (!matches(previous)) throw new IssueRequestError("requestId уже относится к другому выпуску", 409) + return { jobId: id } + } + if (!await deps.getServer(row.serverId)) throw new IssueRequestError("Сервер не найден", 404) + const created = await deps.createJob(row) + if (!created) { + const existing = await deps.getJob(id) + if (!existing || !matches(existing)) throw new IssueRequestError("requestId уже относится к другому выпуску", 409) + } else deps.queue(id) + return { jobId: id } +} diff --git a/backend/src/services/certificates-service.ts b/backend/src/services/certificates-service.ts index 4f6512f1..0d7f1b39 100644 --- a/backend/src/services/certificates-service.ts +++ b/backend/src/services/certificates-service.ts @@ -141,6 +141,13 @@ export async function createIssueJobRecord(input: { }) } +export async function createIssueJobRecordIfAbsent(input: { id: string; serverId: string; certName: string; domainNames: string[]; keyType: string; trustStore: string; source: "manual" }) { + const rows = await db.insert(certificateIssueJobs).values({ + ...input, serverId: Number(input.serverId), status: "queued", step: "queued", requestedAt: new Date().toISOString(), + }).onConflictDoNothing({ target: certificateIssueJobs.id }).returning({ id: certificateIssueJobs.id }) + return rows.length > 0 +} + export async function updateIssueJobRecord( id: string, patch: Partial<{ diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index bc425d94..956dbc6b 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.3 — повторяемое создание issue с requestId, затем frontend checkpoint. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.3; таблица требований — [этап 11А](ux-ui-stage-11a.md). Сертификаты и весь 11А ещё не приняты. +Актуальная точка продолжения: 11А.21.4 — frontend checkpoint с requestId, resume и отмена выпуска. 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 05a1eda0..0355e391 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.2; пользователи/контейнеры приняты локально, сертификаты: state/URL/API/renew/ACME готовы; далее 11А.21.3 | +| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.21.3; пользователи/контейнеры приняты локально, сертификаты: state/URL/API/renew/ACME готовы; далее 11А.21.4 | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,13 @@ +## Блок 11А.21.3 — устойчивое создание issue задачи (2026-10-01) + +- В запрос выпуска добавлен необязательный UUID requestId. Backend использует существующий primary key задачи и INSERT ON CONFLICT DO NOTHING: повтор идентичной цели возвращает тот же jobId без повторной очереди, другая цель/source с тем же ID получает 409. Старые клиенты без requestId продолжают получать независимые UUID. Миграция БД не нужна. +- Request ID сохраняется и проверяется frontend API; чужое подтверждение отклоняется. Server ID должен быть безопасным положительным целым, имя/домены не могут быть пустыми после trim. Контракт нормализует поля перед созданием. +- 4 request helper unit (включая конкурентный повтор), HTTP handler fault проверки, backend test:certificates/build, 6 frontend API unit, polling unit, frontend TypeScript и профильный lint прошли. Проверки helper/HTTP используют тестовый repository; реальный PostgreSQL INSERT и RouterOS выпуск остаются внешней приёмкой. Runner и сетевой порядок операций не менялись. +- Следующий блок 11А.21.4: frontend checkpoint с заранее созданным requestId и неизменной целью, resume GET/poll без нового ID, сохранение в пределах источника, отмена и HTTP fault/context проверки. + ## Блок 11А.21.2 — отменяемое наблюдение issue задачи (2026-10-01) - pollCertificateIssue проверяет jobId, сервер, имя и домены перед наблюдением результата. queued/running не считаются успехом; done требует шага done. failed возвращает реальную ошибку задачи. diff --git a/docs/ux-ui-stage-11a.md b/docs/ux-ui-stage-11a.md index b03cb353..7bb3e0d0 100644 --- a/docs/ux-ui-stage-11a.md +++ b/docs/ux-ui-stage-11a.md @@ -256,3 +256,10 @@ - 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.3 — устойчивое создание issue задачи (2026-10-01) + +- В запрос выпуска добавлен необязательный UUID requestId. Backend использует существующий primary key задачи и INSERT ON CONFLICT DO NOTHING: повтор идентичной цели возвращает тот же jobId без повторной очереди, другая цель/source с тем же ID получает 409. Старые клиенты без requestId продолжают получать независимые UUID. Миграция БД не нужна. +- Request ID сохраняется и проверяется frontend API; чужое подтверждение отклоняется. Server ID должен быть безопасным положительным целым, имя/домены не могут быть пустыми после trim. Контракт нормализует поля перед созданием. +- 4 request helper unit (включая конкурентный повтор), HTTP handler fault проверки, backend test:certificates/build, 6 frontend API unit, polling unit, frontend TypeScript и профильный lint прошли. Проверки helper/HTTP используют тестовый repository; реальный PostgreSQL INSERT и RouterOS выпуск остаются внешней приёмкой. Runner и сетевой порядок операций не менялись. +- Следующий блок 11А.21.4: frontend checkpoint с заранее созданным requestId и неизменной целью, resume GET/poll без нового ID, сохранение в пределах источника, отмена и HTTP fault/context проверки. diff --git a/packages/contracts/src/certificates.ts b/packages/contracts/src/certificates.ts index 3d8f155f..dbb33574 100644 --- a/packages/contracts/src/certificates.ts +++ b/packages/contracts/src/certificates.ts @@ -51,9 +51,10 @@ export const certificateTrustStoreSchema = z.enum([ ]) export const certificateIssueRequestSchema = z.object({ - serverId: z.union([z.string(), z.number()]), - certName: z.string().min(1).max(64), - domainNames: z.array(z.string().min(1)).min(1), + serverId: z.union([z.string().regex(/^[1-9][0-9]*$/).refine(value => Number.isSafeInteger(Number(value))), z.number().int().positive().max(Number.MAX_SAFE_INTEGER)]), + requestId: z.string().uuid().optional(), + certName: z.string().trim().min(1).max(64), + domainNames: z.array(z.string().trim().min(1)).min(1), keyType: z.enum(["rsa2048", "ec256"]).optional(), trustStore: z.array(certificateTrustStoreSchema).optional(), }) diff --git a/shared/api/certificates.test.ts b/shared/api/certificates.test.ts index b725eae0..56ad9da5 100644 --- a/shared/api/certificates.test.ts +++ b/shared/api/certificates.test.ts @@ -68,3 +68,13 @@ test("Cloudflare failure preserves backend message and malformed success is reje assert.equal((await testAcmeSettings(base)).ok, false) } finally { globalThis.fetch = original } }) + +test("requestId survives POST and mismatched acknowledgement is rejected", async () => { + const original = globalThis.fetch; const requestId = "11111111-1111-4111-8111-111111111111"; let returnedId = "other"; let body: Record = {} + globalThis.fetch = async (_url, init) => { body = JSON.parse(String(init?.body)); return new Response(JSON.stringify({ jobId: returnedId }), { status: 200 }) } + try { + await assert.rejects(() => createCertificateIssueJob(base, { ...issue, requestId }), /другому запросу/) + assert.equal(body.requestId, requestId) + returnedId = requestId; assert.equal((await createCertificateIssueJob(base, { ...issue, requestId })).jobId, requestId) + } finally { globalThis.fetch = original } +}) diff --git a/shared/api/certificates.ts b/shared/api/certificates.ts index 947f40fd..988bc225 100644 --- a/shared/api/certificates.ts +++ b/shared/api/certificates.ts @@ -31,7 +31,9 @@ export async function refreshCertificates(baseUrl: string, options?: RequestInit export async function createCertificateIssueJob(baseUrl: string, payload: CertificateIssueRequest, options?: RequestInit) { const body = certificateIssueRequestSchema.parse(payload) - return issueAckSchema.parse(await certificateJson(baseUrl, "/api/certificates/issue", { ...options, method: "POST", body: JSON.stringify(body) })) + const result = issueAckSchema.parse(await certificateJson(baseUrl, "/api/certificates/issue", { ...options, method: "POST", body: JSON.stringify(body) })) + if (body.requestId && result.jobId !== body.requestId) throw new Error("Подтверждение относится к другому запросу выпуска") + return result } export async function getCertificateIssueJob(baseUrl: string, jobId: string, options?: RequestInit) {