fix(api): проверять ответы настроек и выпуска сертификатов
This commit is contained in:
1 parent
adb6589816
commit
df89e88f4e
5 files changed
+139
-65
No files matched your search
+1
-1
@@ -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/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) |
|
| Контейнеры | [Обзор](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А.18 — строгие API ACME/renew/issue, затем формы настроек и polling выпуска. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.3; таблица требований — [этап 11А](ux-ui-stage-11a.md). Сертификаты и весь 11А ещё не приняты.
|
Актуальная точка продолжения: 11А.19 — достоверные renew настройки, затем ACME и polling выпуска. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.3; таблица требований — [этап 11А](ux-ui-stage-11a.md). Сертификаты и весь 11А ещё не приняты.
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
||||||
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
||||||
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
|
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
|
||||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.17.3; пользователи/контейнеры приняты локально, сертификаты: state/URL подключены; далее 11А.18 |
|
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.18; пользователи/контейнеры приняты локально, сертификаты: state/URL/строгий API подключены; далее 11А.19 |
|
||||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||||
|
|
||||||
@@ -36,6 +36,12 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Блок 11А.18 — строгие API настроек и выпуска сертификатов (2026-10-01)
|
||||||
|
|
||||||
|
- ACME/renew settings и issue request/ответы проверяются существующими contracts схемами. Malformed/204 не считаются успешным сохранением; пустой jobId и чужая задача в polling отклоняются. Legacy list/refresh тоже валидируют DTO.
|
||||||
|
- Все certificate API принимают caller AbortSignal вместе со сроком 30s. Ошибка Cloudflare с backend message сохраняет понятную причину вместо HTTP Bad Request. Payload проверяется до mutation запроса.
|
||||||
|
- 5 новых API fault тестов + 5 snapshot/reader тестов, TypeScript и профильный ESLint прошли. Следующий блок 11А.19 — достоверное чтение и сохранение renew настроек, затем ACME и issue polling. Реальные ACME/RouterOS операции пока не приняты.
|
||||||
|
|
||||||
## Блок 11А.17.3 — URL и изоляция источника сертификатов (2026-10-01)
|
## Блок 11А.17.3 — URL и изоляция источника сертификатов (2026-10-01)
|
||||||
|
|
||||||
- server/status/q читаются из URL: reload/Back сохраняют контекст, чужие параметры и hash не теряются. Дубликаты, неизвестные статусы, некорректные ID и слишком длинный поиск явно отклоняются; сброс не удаляет чужой контекст. Mock srv ID поддержан отдельно.
|
- server/status/q читаются из URL: reload/Back сохраняют контекст, чужие параметры и hash не теряются. Дубликаты, неизвестные статусы, некорректные ID и слишком длинный поиск явно отклоняются; сброс не удаляет чужой контекст. Mock srv ID поддержан отдельно.
|
||||||
|
|||||||
@@ -220,3 +220,9 @@
|
|||||||
- Недоступный сервер не заменяется всеми устройствами; ошибка ссылки скрывает строки. Нет совпадений отличается от подтверждённого пустого каталога. Моковые данные подписаны явно.
|
- Недоступный сервер не заменяется всеми устройствами; ошибка ссылки скрывает строки. Нет совпадений отличается от подтверждённого пустого каталога. Моковые данные подписаны явно.
|
||||||
- 3 URL unit, 6 URL E2E и 4 E2E смены backend/mode с поздним успехом/ошибкой прошли. ESLint/TypeScript прошли; production сборка URL/Suspense прошла до финального добавления подписи моков, после него повторены TypeScript и E2E.
|
- 3 URL unit, 6 URL E2E и 4 E2E смены backend/mode с поздним успехом/ошибкой прошли. ESLint/TypeScript прошли; production сборка URL/Suspense прошла до финального добавления подписи моков, после него повторены TypeScript и E2E.
|
||||||
- Далее 11А.18: строгие ответы/отмена API ACME/renew/issue, затем достоверные формы настроек и issue checkpoint/polling. Настройки, выпуск и общая приёмка сертификатов пока не приняты.
|
- Далее 11А.18: строгие ответы/отмена API ACME/renew/issue, затем достоверные формы настроек и issue checkpoint/polling. Настройки, выпуск и общая приёмка сертификатов пока не приняты.
|
||||||
|
|
||||||
|
## Блок 11А.18 — строгие API настроек и выпуска сертификатов (2026-10-01)
|
||||||
|
|
||||||
|
- ACME/renew settings и issue request/ответы проверяются существующими contracts схемами. Malformed/204 не считаются успешным сохранением; пустой jobId и чужая задача в polling отклоняются. Legacy list/refresh тоже валидируют DTO.
|
||||||
|
- Все certificate API принимают caller AbortSignal вместе со сроком 30s. Ошибка Cloudflare с backend message сохраняет понятную причину вместо HTTP Bad Request. Payload проверяется до mutation запроса.
|
||||||
|
- 5 новых API fault тестов + 5 snapshot/reader тестов, TypeScript и профильный ESLint прошли. Следующий блок 11А.19 — достоверное чтение и сохранение renew настроек, затем ACME и issue polling. Реальные ACME/RouterOS операции пока не приняты.
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { test } from "node:test"
|
||||||
|
import { createCertificateIssueJob, getCertificateIssueJob, getAcmeSettings, putAcmeSettings, testAcmeSettings, getCertificateRenewSettings, putCertificateRenewSettings } from "./certificates"
|
||||||
|
const base = "https://api.example.test"
|
||||||
|
const issue = { serverId: "1", certName: "cert", domainNames: ["test.example"] }
|
||||||
|
const acme = { directoryUrl: "https://acme.example.test/directory", tokenConfigured: false }
|
||||||
|
const renew = { enabled: false, intervalSec: 300, renewBeforeDays: 30 }
|
||||||
|
const job = { id: "job-1", serverId: "1", certName: "cert", domainNames: ["test.example"], status: "queued", step: "queued", requestedAt: "2026-10-01T00:00:00Z" }
|
||||||
|
|
||||||
|
test("settings readers reject malformed and empty responses instead of defaults", async () => {
|
||||||
|
const original = globalThis.fetch
|
||||||
|
try {
|
||||||
|
for (const payload of [{}, [], { ...acme, tokenConfigured: "false" }, { ...renew, enabled: "false" }]) {
|
||||||
|
globalThis.fetch = async () => new Response(JSON.stringify(payload), { status: 200 })
|
||||||
|
await assert.rejects(() => getAcmeSettings(base))
|
||||||
|
await assert.rejects(() => getCertificateRenewSettings(base))
|
||||||
|
}
|
||||||
|
globalThis.fetch = async () => new Response(null, { status: 204 })
|
||||||
|
await assert.rejects(() => putAcmeSettings(base, {}))
|
||||||
|
await assert.rejects(() => putCertificateRenewSettings(base, { enabled: true }))
|
||||||
|
} finally { globalThis.fetch = original }
|
||||||
|
})
|
||||||
|
|
||||||
|
test("strict settings preserve false, carry caller cancellation and PUT body", async () => {
|
||||||
|
const original = globalThis.fetch; let init: RequestInit | undefined
|
||||||
|
globalThis.fetch = async (url, options) => { init = options; return new Response(JSON.stringify(String(url).includes("renew") ? renew : acme), { status: 200 }) }
|
||||||
|
try {
|
||||||
|
const abort = new AbortController()
|
||||||
|
assert.equal((await getAcmeSettings(base, { signal: abort.signal })).tokenConfigured, false)
|
||||||
|
const signal = init?.signal; abort.abort(); assert.equal(signal?.aborted, true)
|
||||||
|
assert.equal((await putCertificateRenewSettings(base, { enabled: false })).enabled, false)
|
||||||
|
assert.equal(init?.method, "PUT"); assert.equal(init?.body, JSON.stringify({ enabled: false }))
|
||||||
|
} finally { globalThis.fetch = original }
|
||||||
|
})
|
||||||
|
|
||||||
|
test("invalid request fields fail before sending mutation", async () => {
|
||||||
|
const original = globalThis.fetch; let calls = 0
|
||||||
|
globalThis.fetch = async () => { calls++; return new Response("{}", { status: 200 }) }
|
||||||
|
try {
|
||||||
|
await assert.rejects(() => putAcmeSettings(base, { directoryUrl: "bad" }))
|
||||||
|
await assert.rejects(() => putCertificateRenewSettings(base, { intervalSec: 0 }))
|
||||||
|
await assert.rejects(() => createCertificateIssueJob(base, { ...issue, domainNames: [] }))
|
||||||
|
assert.equal(calls, 0)
|
||||||
|
} finally { globalThis.fetch = original }
|
||||||
|
})
|
||||||
|
|
||||||
|
test("issue acknowledgment and polling require matching job identity", async () => {
|
||||||
|
const original = globalThis.fetch
|
||||||
|
try {
|
||||||
|
for (const payload of [{}, { jobId: "" }, { jobId: " " }]) {
|
||||||
|
globalThis.fetch = async () => new Response(JSON.stringify(payload), { status: 200 })
|
||||||
|
await assert.rejects(() => createCertificateIssueJob(base, issue))
|
||||||
|
}
|
||||||
|
globalThis.fetch = async () => new Response(JSON.stringify(job), { status: 200 })
|
||||||
|
assert.equal((await getCertificateIssueJob(base, "job-1")).status, "queued")
|
||||||
|
await assert.rejects(() => getCertificateIssueJob(base, "job-other"), /другой задаче/)
|
||||||
|
} finally { globalThis.fetch = original }
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Cloudflare failure preserves backend message and malformed success is rejected", async () => {
|
||||||
|
const original = globalThis.fetch
|
||||||
|
try {
|
||||||
|
globalThis.fetch = async () => new Response(JSON.stringify({ ok: false, message: "Cloudflare отказал" }), { status: 400 })
|
||||||
|
await assert.rejects(() => testAcmeSettings(base), /Cloudflare отказал/)
|
||||||
|
globalThis.fetch = async () => new Response(JSON.stringify({}), { status: 200 })
|
||||||
|
await assert.rejects(() => testAcmeSettings(base))
|
||||||
|
globalThis.fetch = async () => new Response(JSON.stringify({ ok: false, message: "denied" }), { status: 200 })
|
||||||
|
assert.equal((await testAcmeSettings(base)).ok, false)
|
||||||
|
} finally { globalThis.fetch = original }
|
||||||
|
})
|
||||||
+55
-63
@@ -1,85 +1,77 @@
|
|||||||
import { certificatesStateSchema } from "@mmapp/contracts/certificates"
|
import { z } from "zod"
|
||||||
import type {
|
import {
|
||||||
AcmeCloudflareSettingsDto,
|
acmeCloudflareSettingsDtoSchema,
|
||||||
CertificateIssueJob,
|
certificateIssueJobSchema,
|
||||||
CertificateIssueRequest,
|
certificateIssueRequestSchema,
|
||||||
CertificateRenewSettingsDto,
|
certificateRenewSettingsDtoSchema,
|
||||||
CertificatesListResponse,
|
certificatesListResponseSchema,
|
||||||
|
certificatesStateSchema,
|
||||||
|
putAcmeCloudflareSettingsSchema,
|
||||||
|
putCertificateRenewSettingsSchema,
|
||||||
|
testAcmeCloudflareSettingsSchema,
|
||||||
} from "@mmapp/contracts/certificates"
|
} from "@mmapp/contracts/certificates"
|
||||||
import { requestJson } from "@/shared/api/http-client"
|
import type { CertificateIssueRequest } from "@mmapp/contracts/certificates"
|
||||||
|
import { ApiClientError, requestJson } from "@/shared/api/http-client"
|
||||||
|
|
||||||
export async function listCertificates(baseUrl: string): Promise<CertificatesListResponse> {
|
const issueAckSchema = z.object({ jobId: z.string().min(1).refine(value => value.trim() === value) })
|
||||||
return requestJson<CertificatesListResponse>(baseUrl, "/api/certificates")
|
const acmeTestSchema = z.object({ ok: z.boolean(), message: z.string().optional() })
|
||||||
|
|
||||||
|
function certificateJson(baseUrl: string, path: string, options?: RequestInit) {
|
||||||
|
const signal = AbortSignal.any([...(options?.signal ? [options.signal] : []), AbortSignal.timeout(30_000)])
|
||||||
|
return requestJson<unknown>(baseUrl, path, { ...options, signal })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshCertificates(baseUrl: string): Promise<CertificatesListResponse> {
|
export async function listCertificates(baseUrl: string, options?: RequestInit) {
|
||||||
return requestJson<CertificatesListResponse>(baseUrl, "/api/certificates/refresh", { method: "POST" })
|
return certificatesListResponseSchema.parse(await certificateJson(baseUrl, "/api/certificates", { ...options, method: "GET" }))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createCertificateIssueJob(
|
export async function refreshCertificates(baseUrl: string, options?: RequestInit) {
|
||||||
baseUrl: string,
|
return certificatesListResponseSchema.parse(await certificateJson(baseUrl, "/api/certificates/refresh", { ...options, method: "POST" }))
|
||||||
payload: CertificateIssueRequest,
|
|
||||||
): Promise<{ jobId: string }> {
|
|
||||||
return requestJson<{ jobId: string }>(baseUrl, "/api/certificates/issue", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCertificateIssueJob(
|
export async function createCertificateIssueJob(baseUrl: string, payload: CertificateIssueRequest, options?: RequestInit) {
|
||||||
baseUrl: string,
|
const body = certificateIssueRequestSchema.parse(payload)
|
||||||
jobId: string,
|
return issueAckSchema.parse(await certificateJson(baseUrl, "/api/certificates/issue", { ...options, method: "POST", body: JSON.stringify(body) }))
|
||||||
): Promise<CertificateIssueJob> {
|
|
||||||
return requestJson<CertificateIssueJob>(baseUrl, `/api/certificates/issue/${encodeURIComponent(jobId)}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAcmeSettings(baseUrl: string): Promise<AcmeCloudflareSettingsDto> {
|
export async function getCertificateIssueJob(baseUrl: string, jobId: string, options?: RequestInit) {
|
||||||
return requestJson<AcmeCloudflareSettingsDto>(baseUrl, "/api/certificates/acme-settings")
|
const result = certificateIssueJobSchema.parse(await certificateJson(baseUrl, "/api/certificates/issue/" + encodeURIComponent(jobId), { ...options, method: "GET" }))
|
||||||
|
if (result.id !== jobId) throw new Error("Ответ относится к другой задаче выпуска")
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function putAcmeSettings(
|
export async function getAcmeSettings(baseUrl: string, options?: RequestInit) {
|
||||||
baseUrl: string,
|
return acmeCloudflareSettingsDtoSchema.parse(await certificateJson(baseUrl, "/api/certificates/acme-settings", { ...options, method: "GET" }))
|
||||||
payload: {
|
|
||||||
directoryUrl?: string
|
|
||||||
defaultZoneId?: string | null
|
|
||||||
cloudflareApiToken?: string | null
|
|
||||||
},
|
|
||||||
): Promise<AcmeCloudflareSettingsDto> {
|
|
||||||
return requestJson<AcmeCloudflareSettingsDto>(baseUrl, "/api/certificates/acme-settings", {
|
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function testAcmeSettings(
|
export async function putAcmeSettings(baseUrl: string, payload: { directoryUrl?: string; defaultZoneId?: string | null; cloudflareApiToken?: string | null }, options?: RequestInit) {
|
||||||
baseUrl: string,
|
const body = putAcmeCloudflareSettingsSchema.parse(payload)
|
||||||
payload?: { cloudflareApiToken?: string },
|
return acmeCloudflareSettingsDtoSchema.parse(await certificateJson(baseUrl, "/api/certificates/acme-settings", { ...options, method: "PUT", body: JSON.stringify(body) }))
|
||||||
): Promise<{ ok: boolean; message?: string }> {
|
|
||||||
return requestJson<{ ok: boolean; message?: string }>(baseUrl, "/api/certificates/acme-settings/test", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(payload ?? {}),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCertificateRenewSettings(baseUrl: string): Promise<CertificateRenewSettingsDto> {
|
export async function testAcmeSettings(baseUrl: string, payload?: { cloudflareApiToken?: string }, options?: RequestInit) {
|
||||||
return requestJson<CertificateRenewSettingsDto>(baseUrl, "/api/certificates/renew-settings")
|
const body = testAcmeCloudflareSettingsSchema.parse(payload ?? {})
|
||||||
|
try {
|
||||||
|
return acmeTestSchema.parse(await certificateJson(baseUrl, "/api/certificates/acme-settings/test", { ...options, method: "POST", body: JSON.stringify(body) }))
|
||||||
|
} catch (error) {
|
||||||
|
// Backend возвращает message для отказа Cloudflare, а не стандартное error.
|
||||||
|
if (error instanceof ApiClientError) {
|
||||||
|
const failure = acmeTestSchema.safeParse(error.payload)
|
||||||
|
if (failure.success && !failure.data.ok && failure.data.message) throw new ApiClientError(failure.data.message, error.status, error.payload)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function putCertificateRenewSettings(
|
export async function getCertificateRenewSettings(baseUrl: string, options?: RequestInit) {
|
||||||
baseUrl: string,
|
return certificateRenewSettingsDtoSchema.parse(await certificateJson(baseUrl, "/api/certificates/renew-settings", { ...options, method: "GET" }))
|
||||||
payload: {
|
}
|
||||||
enabled?: boolean
|
|
||||||
intervalSec?: number
|
export async function putCertificateRenewSettings(baseUrl: string, payload: { enabled?: boolean; intervalSec?: number; renewBeforeDays?: number }, options?: RequestInit) {
|
||||||
renewBeforeDays?: number
|
const body = putCertificateRenewSettingsSchema.parse(payload)
|
||||||
},
|
return certificateRenewSettingsDtoSchema.parse(await certificateJson(baseUrl, "/api/certificates/renew-settings", { ...options, method: "PUT", body: JSON.stringify(body) }))
|
||||||
): Promise<CertificateRenewSettingsDto> {
|
|
||||||
return requestJson<CertificateRenewSettingsDto>(baseUrl, "/api/certificates/renew-settings", {
|
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readCertificatesState(baseUrl: string, options?: RequestInit) {
|
export async function readCertificatesState(baseUrl: string, options?: RequestInit) {
|
||||||
const signal = AbortSignal.any([...(options?.signal ? [options.signal] : []), AbortSignal.timeout(30_000)])
|
return certificatesStateSchema.parse(await certificateJson(baseUrl, "/api/certificates/state", { ...options, method: "GET" }))
|
||||||
return certificatesStateSchema.parse(await requestJson<unknown>(baseUrl, "/api/certificates/state", { ...options, method: "GET", signal }))
|
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user