From 26869632a08f8ec1969ff99676b92bf2671466b5 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 1 Oct 2026 11:18:32 +0700 Subject: [PATCH] =?UTF-8?q?fix(api):=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B4?= =?UTF-8?q?=D0=B0=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BD=D0=B0=D0=B1=D0=BB=D1=8E?= =?UTF-8?q?=D0=B4=D0=B0=D0=B5=D0=BC=D1=8B=D0=B5=20=D1=81=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D1=8F=D0=BD=D0=B8=D1=8F=20=D1=81=D0=B5=D1=80=D1=82=D0=B8?= =?UTF-8?q?=D1=84=D0=B8=D0=BA=D0=B0=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/package.json | 5 ++- backend/src/routes/certificates-state.test.ts | 15 +++++++ backend/src/routes/certificates.ts | 14 ++++++- backend/src/services/certificates-service.ts | 6 +++ .../src/services/certificates-state.test.ts | 24 +++++++++++ backend/src/services/certificates-state.ts | 41 +++++++++++++++++++ docs/ux-ui-progress.md | 11 ++++- docs/ux-ui-stage-11a.md | 10 +++++ packages/contracts/src/certificates.ts | 30 ++++++++++++++ 9 files changed, 151 insertions(+), 5 deletions(-) create mode 100644 backend/src/routes/certificates-state.test.ts create mode 100644 backend/src/services/certificates-state.test.ts create mode 100644 backend/src/services/certificates-state.ts diff --git a/backend/package.json b/backend/package.json index 8eecfa9c..7f455c67 100644 --- a/backend/package.json +++ b/backend/package.json @@ -26,10 +26,11 @@ "test:ros-file": "tsx src/services/ros-file-contents.test.ts", "test:backups": "tsx src/services/s3-backup-client.test.ts", "test:live-maps": "tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/services/containers-live.test.ts && tsx src/services/firewall-poll.test.ts && tsx src/services/gre-catalog-poll.test.ts && tsx src/routes/filters-gre-catalog.test.ts && tsx src/routes/bgp-catalog.test.ts && tsx src/services/bgp-catalog-parse.test.ts && tsx src/routes/ospf-catalog.test.ts && tsx src/services/ospf-observed-values.test.ts", - "test": "npm run test:probes && npm run test:exec && npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:live-maps && npm run test:ospf-optimizer && npm run test:recursive-routes && npm run test:config-sync && npm run test:ros-file && npm run test:containers", + "test": "npm run test:probes && npm run test:exec && npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:live-maps && npm run test:ospf-optimizer && npm run test:recursive-routes && npm run test:config-sync && npm run test:ros-file && npm run test:containers && npm run test:certificates", "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:containers": "tsx src/services/containers-state.test.ts && tsx src/routes/containers-state.test.ts", + "test:certificates": "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-state.test.ts b/backend/src/routes/certificates-state.test.ts new file mode 100644 index 00000000..bebf8df2 --- /dev/null +++ b/backend/src/routes/certificates-state.test.ts @@ -0,0 +1,15 @@ +import assert from "node:assert/strict" +import Fastify from "fastify" +import {validatorCompiler,serializerCompiler} from "@fastify/type-provider-zod" +import {createCertificatesRoutes} from "./certificates.js" +import {permissionForRequest} from "../lib/permissions.js" +import type {CertificatesState} from "@mmapp/contracts/certificates" +let state:unknown={collectedAt:new Date().toISOString(),servers:[]},failed=false +const app=Fastify();app.setValidatorCompiler(validatorCompiler);app.setSerializerCompiler(serializerCompiler) +await app.register(createCertificatesRoutes({getState:async()=>{if(failed)throw new Error("DB unavailable");return state as CertificatesState}}),{prefix:"/api"}) +const read=()=>app.inject({method:"GET",url:"/api/certificates/state"}) +assert.equal((await read()).statusCode,200);assert.deepEqual((await read()).json().servers,[]) +state={collectedAt:new Date().toISOString(),servers:[{serverId:"1",serverName:"Router",collectedAt:new Date().toISOString(),certificates:[],error:"offline"}]};assert.equal((await read()).statusCode,200);assert.equal((await read()).json().servers[0].error,"offline") +for(const value of [{certificates:[]},{collectedAt:"bad",servers:[]},{collectedAt:new Date().toISOString(),servers:[{}]}]){state=value;assert.equal((await read()).statusCode,502)} +failed=true;assert.equal((await read()).statusCode,500);assert.equal(permissionForRequest("GET","/api/certificates/state"),"mm:certificates:read") +await app.close();console.log("Certificates HTTP: known empty/partial, invalid 502, DB failure 500, existing RBAC") diff --git a/backend/src/routes/certificates.ts b/backend/src/routes/certificates.ts index 97137da9..1c914ce0 100644 --- a/backend/src/routes/certificates.ts +++ b/backend/src/routes/certificates.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto" import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" import { certificateIssueRequestSchema, + certificatesStateSchema, putAcmeCloudflareSettingsSchema, putCertificateRenewSettingsSchema, testAcmeCloudflareSettingsSchema, @@ -17,12 +18,19 @@ import { getIssueJobRecord, getServerRowByIdString, listCertificatesFromServers, + listCertificatesState, toIssueJobDto, updateAcmeSettings, updateCertificateRenewSettings, } from "../services/certificates-service.js" -const certificatesRoutes: FastifyPluginAsyncZod = async (app) => { +export function createCertificatesRoutes(deps: { getState?: typeof listCertificatesState } = {}): FastifyPluginAsyncZod { + return async (app) => { + app.get("/certificates/state", async (_req, reply) => { + const parsed = certificatesStateSchema.safeParse(await (deps.getState ?? listCertificatesState)()) + if (!parsed.success) return reply.status(502).send({ error: "Некорректный каталог сертификатов" }) + return reply.send(parsed.data) + }) app.get("/certificates", async (_req, reply) => { return reply.send(await listCertificatesFromServers()) }) @@ -109,4 +117,6 @@ const certificatesRoutes: FastifyPluginAsyncZod = async (app) => { }) } -export default certificatesRoutes +} + +export default createCertificatesRoutes() diff --git a/backend/src/services/certificates-service.ts b/backend/src/services/certificates-service.ts index 183a9897..a94ef219 100644 --- a/backend/src/services/certificates-service.ts +++ b/backend/src/services/certificates-service.ts @@ -270,3 +270,9 @@ export async function hasActiveIssueJob(serverId: string, certName: string): Pro const sid = Number(serverId) return rows.some((r) => r.serverId === sid && r.certName === certName) } + +export async function listCertificatesState() { + const { collectCertificatesState } = await import("./certificates-state.js") + const enabledServers = await db.select().from(servers).where(eq(servers.enabled, true)) + return collectCertificatesState(enabledServers, server => { const client=MikrotikClient.fromServer(server); return () => client.get("/certificate") }) +} diff --git a/backend/src/services/certificates-state.test.ts b/backend/src/services/certificates-state.test.ts new file mode 100644 index 00000000..1cb388e4 --- /dev/null +++ b/backend/src/services/certificates-state.test.ts @@ -0,0 +1,24 @@ +import assert from "node:assert/strict" +import {mapObservedCertificate,readCertificateServerState,collectCertificatesState} from "./certificates-state.js" +import {certificatesStateSchema,certificateObservedSchema} from "@mmapp/contracts/certificates" +const server={id:1,name:"Router"},now=new Date("2026-10-01T00:00:00Z") +const raw={".id":"*A",name:"cert-live",flags:"KT","invalid-before":"2026-09-01T00:00:00Z","invalid-after":"2026-11-01T00:00:00Z","key-size":"2048",trusted:"true"} +const good=mapObservedCertificate(server,raw,now) +assert.equal(good.status,"valid");assert.equal(good.daysLeft,31);assert.equal(good.keySize,2048);assert.equal(good.trusted,true);assert.equal(good.rosId,"*A") +const unknown=mapObservedCertificate(server,{name:"unsigned"},now) +assert.equal(unknown.daysLeft,null);assert.equal(unknown.status,"unknown");assert.equal(unknown.trusted,null);assert.equal(unknown.keySize,null);assert.equal(unknown.rosId,null) +for(const date of ["yesterday","2026-02-30T00:00:00Z","oct/01/2026 12:00:00","2026-11-01"]){const row=mapObservedCertificate(server,{...raw,"invalid-after":date},now);assert.equal(row.daysLeft,null,date);assert.equal(row.status,"unknown");assert.equal(row.rawValidUntil,date)} +assert.equal(mapObservedCertificate(server,{...raw,"invalid-before":"2026-10-01T03:00:00+03:00"},now).validFrom,now.toISOString()) +assert.equal(mapObservedCertificate(server,{...raw,"invalid-before":"2026-12-01T00:00:00Z"},now).status,"unknown") +assert.equal(mapObservedCertificate(server,{...raw,flags:undefined,revoked:undefined},now).status,"unknown") +assert.equal(mapObservedCertificate(server,{...raw,flags:"R","invalid-after":undefined},now).status,"revoked") +assert.equal(mapObservedCertificate(server,{...raw,flags:"E","invalid-after":undefined},now).status,"expired") +assert.equal(mapObservedCertificate(server,{...raw,"invalid-after":"2026-09-01T00:00:00Z"},now).status,"expired") +for(const size of ["0","bad","256x","-1","256.5"])assert.equal(mapObservedCertificate(server,{...raw,"key-size":size},now).keySize,null) +assert.equal((await readCertificateServerState(server,async()=>[])).error,null) +for(const value of [{certificates:[]},[{}],[raw,raw],[{...raw,flags:true}]])assert.ok((await readCertificateServerState(server,async()=>value)).error) +const partial=await collectCertificatesState([server,{id:2,name:"offline"}],srv=>async()=>{if(srv.id===2)throw new Error("offline");return [raw]}) +assert.equal(partial.servers[0].certificates.length,1);assert.equal(partial.servers[1].error,"offline");assert.equal((await collectCertificatesState([],()=>async()=>[])).servers.length,0) +for(const value of [{...partial,servers:[partial.servers[0],partial.servers[0]]},{...partial,servers:[{...partial.servers[0],serverId:"2"}]},{...partial,servers:[{...partial.servers[0],error:"failure"}]}])assert.equal(certificatesStateSchema.safeParse(value).success,false) +assert.equal(certificateObservedSchema.safeParse({...good,validUntil:null}).success,false) +console.log("Certificate observed: unknown/date timezone/invalid calendar, flags, key size, known empty, malformed/duplicate/context, partial devices; no DB/RouterOS") diff --git a/backend/src/services/certificates-state.ts b/backend/src/services/certificates-state.ts new file mode 100644 index 00000000..c7530ab2 --- /dev/null +++ b/backend/src/services/certificates-state.ts @@ -0,0 +1,41 @@ +import {z} from "zod" +import {certificateObservedSchema,certificateServerStateSchema,certificatesStateSchema,type CertificateServerState,type CertificatesState} from "@mmapp/contracts/certificates" +import {mapRosCertificateRow,type RosCertificateRow} from "./certificate-parse.js" +type ServerContext={id:number;name:string} +const rawSchema=z.array(z.record(z.string(),z.string().optional()).refine(row=>!!row.name?.trim(),"Отсутствует имя сертификата")) +const dateSchema=z.string().datetime({offset:true}) +function observedDate(raw:string|undefined) { + const value=raw?.trim() + return value&&dateSchema.safeParse(value).success?new Date(value):null +} +export function mapObservedCertificate(server:ServerContext,row:RosCertificateRow,now=new Date()) { + const base=mapRosCertificateRow(server.id,server.name,row) + if(!base)throw new Error("Отсутствует сертификат") + const from=observedDate(row["invalid-before"]),until=observedDate(row["invalid-after"]) + const flags=row.flags?.toUpperCase() + const revoked=flags?.includes("R")||row.revoked==="true"||row.revoked==="yes" + const expired=flags?.includes("E")||row.expired==="true"||row.expired==="yes"||!!until&&until.getTime()=now.getTime() + const size=row["key-size"]?.trim() + const keySize=size&&/^\d+$/.test(size)&&Number.isSafeInteger(Number(size))&&Number(size)>0?Number(size):null + const trusted=row.trusted==="true"||row.trusted==="yes"?true:row.trusted==="false"||row.trusted==="no"?false:flags!==undefined?flags.includes("T"):null + return certificateObservedSchema.parse({...base,rosId:row[".id"]?.trim()||null, + validFrom:from?.toISOString()??null,validUntil:until?.toISOString()??null, + rawValidFrom:row["invalid-before"]??null,rawValidUntil:row["invalid-after"]??null, + daysLeft:until?Math.ceil((until.getTime()-now.getTime())/86_400_000):null,keySize,trusted, + status:revoked?"revoked":expired?"expired":valid?"valid":"unknown"}) +} +export async function readCertificateServerState(server:ServerContext,read:()=>Promise):Promise { + try{ + const rows=rawSchema.parse(await read()),now=new Date() + const result={serverId:String(server.id),serverName:server.name,collectedAt:now.toISOString(),certificates:rows.map(row=>mapObservedCertificate(server,row,now)),error:null} + return certificateServerStateSchema.parse(result) + }catch(error){ + return certificateServerStateSchema.parse({serverId:String(server.id),serverName:server.name,collectedAt:new Date().toISOString(),certificates:[],error:error instanceof z.ZodError?"Некорректный ответ каталога сертификатов":error instanceof Error?error.message.slice(0,500)||"Ошибка RouterOS":"Ошибка RouterOS"}) + } +} +export async function collectCertificatesState(servers:T[],makeReader:(server:T)=>()=>Promise):Promise{ + const states=await Promise.all(servers.map(server=>readCertificateServerState(server,makeReader(server)))) + return certificatesStateSchema.parse({collectedAt:new Date().toISOString(),servers:states}) +} diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index fd8672cd..357b4bc1 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А.13; пользователи и контейнеры приняты локально, далее сертификаты | +| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.14; пользователи/контейнеры приняты локально, observed API сертификатов подготовлен | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,15 @@ +## Блок 11А.14 — observed state API сертификатов (2026-10-01) + +- Добавлен совместимый GET /certificates/state: collectedAt, отдельные enabled устройства, подтверждённые строки или error. Новое чтение /certificate не использует tolerant getCertificates, скрывавший неверный ответ как []. +- Новый CertificateObserved не подменяет отсутствующие срок/keySize/trusted/rosId. daysLeft nullable, status unknown; даты без часового пояса/некорректного календаря остаются raw полями и не используются для вычисления срока. Известные UTC/offset даты нормализованы; valid требует известных сроков и сведений об отзыве. Неизвестный будущий/неполный сертификат не становится valid. +- Контракт отклоняет дубли/чужой контекст, несогласованные срок/статус и error со строками. Частичный отказ одного устройства сохраняет остальные; общий DB отказ — HTTP 500, неверный envelope — 502. +- Legacy DTO/API, parser ролей, ACME issue/renew и сетевые операции сохранены для совместимости. Frontend ещё читает legacy; эти сценарии не приняты и требуют последующей миграции. +- Backend build, профильный lint, observed service/HTTP tests и прежний certificate-parse test прошли. test:certificates включён в общий test. PostgreSQL/RouterOS/ACME не использованы в тестах; локальные bare-time даты требуют отдельной проверки часового пояса устройства. +- Далее 11А.15: typed frontend reader и immutable merge последних снимков; затем 11А.16 — таблица/детали/unknown KPI, partial сбои, freshness, контекст/права. ACME settings/jobs/экспорт — последующие блоки. Резервирование, 11Б и итоговый 12 ещё не приняты. + ## Блок 11А.13 — контекст и локальная приёмка контейнеров (2026-10-01) - URL восстанавливает server/status/q, поддерживает Back/reload, сохраняет чужие параметры/hash. Дубли, неверные ID/status и слишком длинный поиск показывают ошибку со сбросом. Использован существующий preserveSelection ServerRail; исчезнувшее устройство не заменяет контекст. diff --git a/docs/ux-ui-stage-11a.md b/docs/ux-ui-stage-11a.md index e6257c60..1fb5abfc 100644 --- a/docs/ux-ui-stage-11a.md +++ b/docs/ux-ui-stage-11a.md @@ -153,3 +153,13 @@ - 40 контейнерных E2E, 8 source-context, 10 профильных проверок экспорта, URL unit, TypeScript, профильный lint (включая общую оболочку) и production frontend прошли. Backend state тесты/сборка приняты в 11А.12. Контейнеры приняты локально; создание/логи/редактирование явно disabled, реальный RouterOS/БД требует внешней проверки. - Далее 11А.14: observed state API сертификатов. Исходный mapper делает отсутствующий срок daysLeft=0, status=valid, keySize=0/trusted=false; это нельзя выдавать за наблюдаемые значения. Legacy API/renew/issue должны сохранять совместимость до отдельной миграции; frontend, ACME jobs, настройки и экспорт сертификатов ещё не приняты. + +## Блок 11А.14 — observed state API сертификатов (2026-10-01) + +- Добавлен совместимый GET /certificates/state: collectedAt, отдельные enabled устройства, подтверждённые строки или error. Новое чтение /certificate не использует tolerant getCertificates, скрывавший неверный ответ как []. +- Новый CertificateObserved не подменяет отсутствующие срок/keySize/trusted/rosId. daysLeft nullable, status unknown; даты без часового пояса/некорректного календаря остаются raw полями и не используются для вычисления срока. Известные UTC/offset даты нормализованы; valid требует известных сроков и сведений об отзыве. Неизвестный будущий/неполный сертификат не становится valid. +- Контракт отклоняет дубли/чужой контекст, несогласованные срок/статус и error со строками. Частичный отказ одного устройства сохраняет остальные; общий DB отказ — HTTP 500, неверный envelope — 502. +- Legacy DTO/API, parser ролей, ACME issue/renew и сетевые операции сохранены для совместимости. Frontend ещё читает legacy; эти сценарии не приняты и требуют последующей миграции. +- Backend build, профильный lint, observed service/HTTP tests и прежний certificate-parse test прошли. test:certificates включён в общий test. PostgreSQL/RouterOS/ACME не использованы в тестах; локальные bare-time даты требуют отдельной проверки часового пояса устройства. +- Далее 11А.15: typed frontend reader и immutable merge последних снимков; затем 11А.16 — таблица/детали/unknown KPI, partial сбои, freshness, контекст/права. ACME settings/jobs/экспорт — последующие блоки. Резервирование, 11Б и итоговый 12 ещё не приняты. + diff --git a/packages/contracts/src/certificates.ts b/packages/contracts/src/certificates.ts index dbd92b6e..5dd92131 100644 --- a/packages/contracts/src/certificates.ts +++ b/packages/contracts/src/certificates.ts @@ -127,3 +127,33 @@ export type CertificateIssueRequest = z.infer export type AcmeCloudflareSettingsDto = z.infer export type CertificateRenewSettingsDto = z.infer + +export const certificateObservedSchema = certificateDtoSchema.extend({ + rosId: z.string().min(1).nullable(), + validFrom: z.string().datetime({ offset: true }).nullable(), + validUntil: z.string().datetime({ offset: true }).nullable(), + rawValidFrom: z.string().nullable(), rawValidUntil: z.string().nullable(), + daysLeft: z.number().int().nullable(), keySize: z.number().int().positive().nullable(), + trusted: z.boolean().nullable(), status: z.enum(["valid", "expired", "revoked", "unknown"]), +}).superRefine((value,ctx) => { + if ((value.validUntil === null) !== (value.daysLeft === null)) ctx.addIssue({code:"custom",message:"Срок не соответствует дате"}) + if (value.status === "valid" && (!value.validFrom || !value.validUntil || value.daysLeft === null || value.daysLeft < 0 || Date.parse(value.validFrom)>Date.parse(value.validUntil))) ctx.addIssue({code:"custom",message:"Действительность не подтверждена датами"}) +}) +export const certificateServerStateSchema = z.object({ + serverId: z.string().regex(/^[1-9]\d*$/).refine(value => Number.isSafeInteger(Number(value))), + serverName: z.string(), collectedAt: z.string().datetime({ offset: true }), + certificates: z.array(certificateObservedSchema), + error: z.string().min(1).nullable(), +}).superRefine((value,ctx) => { + if (value.error && value.certificates.length) ctx.addIssue({code:"custom",message:"Ошибка загрузки содержит сертификаты"}) + if (new Set(value.certificates.map(row=>row.id)).size !== value.certificates.length) ctx.addIssue({code:"custom",message:"Дубли сертификатов"}) + if (value.certificates.some(row=>row.serverId !== value.serverId || !row.id.startsWith(value.serverId + ":"))) ctx.addIssue({code:"custom",message:"Чужой контекст сертификата"}) +}) +export const certificatesStateSchema = z.object({ + collectedAt: z.string().datetime({ offset: true }), servers: z.array(certificateServerStateSchema), +}).superRefine((value,ctx) => { + if(new Set(value.servers.map(server=>server.serverId)).size!==value.servers.length)ctx.addIssue({code:"custom",message:"Дубли устройств"}) +}) +export type CertificateObserved = z.infer +export type CertificateServerState = z.infer +export type CertificatesState = z.infer