From c7b4981dff4ec7567b55b5f31ef81de727711b1e Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 1 Oct 2026 11:23:45 +0700 Subject: [PATCH] =?UTF-8?q?fix(api):=20=D1=81=D0=BE=D1=85=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=82=D1=8C=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=D0=B4?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D1=81=D0=BD=D0=B8=D0=BC=D0=BA=D0=B8=20?= =?UTF-8?q?=D1=81=D0=B5=D1=80=D1=82=D0=B8=D1=84=D0=B8=D0=BA=D0=B0=D1=82?= =?UTF-8?q?=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ux-ui-progress.md | 9 +++++++- docs/ux-ui-stage-11a.md | 8 +++++++ lib/certificate-snapshots.test.ts | 35 +++++++++++++++++++++++++++++++ lib/certificate-snapshots.ts | 10 +++++++++ shared/api/certificates.ts | 6 ++++++ 5 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 lib/certificate-snapshots.test.ts create mode 100644 lib/certificate-snapshots.ts diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 357b4bc1..58a6356c 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А.14; пользователи/контейнеры приняты локально, observed API сертификатов подготовлен | +| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.15; пользователи/контейнеры приняты локально, reader/snapshots сертификатов готовы | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,13 @@ +## Блок 11А.15 — typed reader и последние снимки сертификатов (2026-10-01) + +- readCertificatesState использует строгий новый контракт, GET, caller AbortSignal + 30s срок. Unknown поля сохраняются; malformed/duplicate/чужой контекст, 204 и ошибки HTTP не становятся успешным пустым каталогом. +- mergeCertificateSnapshots хранит latest отдельно от confirmed: отказ устройства сохраняет последний успешный снимок с его временем, первоначальный отказ имеет confirmed=null. Подтверждённое empty очищает строки; исчезнувшие enabled устройства удаляются; восстановление заменяет свой снимок без мутации прежнего состояния. +- 5 fault/HTTP unit, TypeScript, профильный lint и production frontend прошли. Reader/merge готовы к подключению; текущая страница ещё использует legacy DTO. Приёмка сертификатов не завершена. +- Далее узкая проверка setup ошибки клиента устройства (11А.16), затем миграция страницы/таблицы/деталей на observed nullable поля и независимые состояния. Не заменять null числом 0/false, не терять partial rows и выбранный сервер; ACME settings/jobs/экспорт проверять отдельными блоками. + ## Блок 11А.14 — observed state API сертификатов (2026-10-01) - Добавлен совместимый GET /certificates/state: collectedAt, отдельные enabled устройства, подтверждённые строки или error. Новое чтение /certificate не использует tolerant getCertificates, скрывавший неверный ответ как []. diff --git a/docs/ux-ui-stage-11a.md b/docs/ux-ui-stage-11a.md index 1fb5abfc..9d7af85e 100644 --- a/docs/ux-ui-stage-11a.md +++ b/docs/ux-ui-stage-11a.md @@ -163,3 +163,11 @@ - 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А.15 — typed reader и последние снимки сертификатов (2026-10-01) + +- readCertificatesState использует строгий новый контракт, GET, caller AbortSignal + 30s срок. Unknown поля сохраняются; malformed/duplicate/чужой контекст, 204 и ошибки HTTP не становятся успешным пустым каталогом. +- mergeCertificateSnapshots хранит latest отдельно от confirmed: отказ устройства сохраняет последний успешный снимок с его временем, первоначальный отказ имеет confirmed=null. Подтверждённое empty очищает строки; исчезнувшие enabled устройства удаляются; восстановление заменяет свой снимок без мутации прежнего состояния. +- 5 fault/HTTP unit, TypeScript, профильный lint и production frontend прошли. Reader/merge готовы к подключению; текущая страница ещё использует legacy DTO. Приёмка сертификатов не завершена. +- Далее узкая проверка setup ошибки клиента устройства (11А.16), затем миграция страницы/таблицы/деталей на observed nullable поля и независимые состояния. Не заменять null числом 0/false, не терять partial rows и выбранный сервер; ACME settings/jobs/экспорт проверять отдельными блоками. + diff --git a/lib/certificate-snapshots.test.ts b/lib/certificate-snapshots.test.ts new file mode 100644 index 00000000..97f1c9b1 --- /dev/null +++ b/lib/certificate-snapshots.test.ts @@ -0,0 +1,35 @@ +import assert from "node:assert/strict" +import {test} from "node:test" +import type {CertificateObserved,CertificatesState} from "@mmapp/contracts/certificates" +import {mergeCertificateSnapshots} from "./certificate-snapshots" +import {readCertificatesState} from "../shared/api/certificates" +const cert:CertificateObserved={id:"1:*A",rosId:"*A",name:"test-cert",serverId:"1",commonName:"test.example",sans:[],issuedBy:"—",validFrom:null,validUntil:null,rawValidFrom:null,rawValidUntil:null,daysLeft:null,keySize:null,trusted:null,status:"unknown",usage:[]} +const node={serverId:"1",serverName:"Router",collectedAt:"2026-10-01T00:00:00Z",certificates:[cert],error:null} +const state:CertificatesState={collectedAt:node.collectedAt,servers:[node]} +test("partial failure retains confirmed device snapshot, latest error and timestamp independently",()=>{ + const first=mergeCertificateSnapshots({},state),second=mergeCertificateSnapshots(first,{...state,servers:[{...node,collectedAt:"2026-10-01T01:00:00Z",certificates:[],error:"offline"}]}) + assert.equal(second["1"].confirmed?.certificates[0].name,cert.name);assert.equal(second["1"].confirmed?.collectedAt,node.collectedAt);assert.equal(second["1"].latest.error,"offline");assert.equal(second["1"].latest.collectedAt,"2026-10-01T01:00:00Z");assert.equal(first["1"].latest.error,null) +}) +test("initial failure stays unknown, confirmed empty clears old rows, removed devices are removed",()=>{ + const failure=mergeCertificateSnapshots({}, {...state,servers:[{...node,certificates:[],error:"offline"}]});assert.equal(failure["1"].confirmed,null) + const good=mergeCertificateSnapshots({},state);const empty=mergeCertificateSnapshots(good,{...state,servers:[{...node,certificates:[]}]});assert.deepEqual(empty["1"].confirmed?.certificates,[]);assert.deepEqual(mergeCertificateSnapshots(good,{...state,servers:[]}),{}) +}) +test("device failures are independent and recovery replaces only its snapshot",()=>{ + const first=mergeCertificateSnapshots({}, {...state,servers:[node,{...node,serverId:"2",certificates:[],error:"offline"}]});assert.equal(first["2"].confirmed,null) + const recovered=mergeCertificateSnapshots(first,{...state,servers:[{...node,certificates:[],error:"offline"},{...node,serverId:"2",certificates:[]}]});assert.equal(recovered["1"].confirmed?.certificates.length,1);assert.deepEqual(recovered["2"].confirmed?.certificates,[]) +}) +test("HTTP reader preserves unknown values and rejects malformed/duplicate/foreign envelopes",async()=>{ + const original=globalThis.fetch;let payload:unknown=state,seen="",signal:AbortSignal|null|undefined + globalThis.fetch=async(input,init)=>{seen=String(input);signal=init?.signal;return new Response(JSON.stringify(payload),{status:200,headers:{"content-type":"application/json"}})} + try{ + const controller=new AbortController();const result=await readCertificatesState("https://api.example.test",{signal:controller.signal});assert.equal(seen,"https://api.example.test/api/certificates/state");assert.equal(result.servers[0].certificates[0].daysLeft,null);assert.equal(result.servers[0].certificates[0].status,"unknown");controller.abort();assert.equal(signal?.aborted,true) + for(const bad of [{certificates:[]},{...state,servers:[node,node]},{...state,servers:[{...node,certificates:[{...cert,serverId:"2"}]}]},{...state,servers:[{...node,certificates:[{...cert,status:"valid"}]}]}]){payload=bad;await assert.rejects(()=>readCertificatesState("https://api.example.test"))} + }finally{globalThis.fetch=original} +}) +test("HTTP error/empty ack do not become a successful empty catalog",async()=>{ + const original=globalThis.fetch + try{ + globalThis.fetch=async()=>new Response(JSON.stringify({error:"forbidden"}),{status:403});await assert.rejects(()=>readCertificatesState("https://api.example.test"),/forbidden/) + globalThis.fetch=async()=>new Response(null,{status:204});await assert.rejects(()=>readCertificatesState("https://api.example.test")) + }finally{globalThis.fetch=original} +}) diff --git a/lib/certificate-snapshots.ts b/lib/certificate-snapshots.ts new file mode 100644 index 00000000..704e33c9 --- /dev/null +++ b/lib/certificate-snapshots.ts @@ -0,0 +1,10 @@ +import type {CertificatesState,CertificateServerState} from "@mmapp/contracts/certificates" +export type CertificateSnapshot = { latest: CertificateServerState; confirmed: CertificateServerState | null } +export type CertificateSnapshots = Record +/** Replace the enabled-device inventory; retain each failed device's last confirmed snapshot. */ +export function mergeCertificateSnapshots(previous:CertificateSnapshots,result:CertificatesState):CertificateSnapshots { + return Object.fromEntries(result.servers.map(node=>[node.serverId,{ + latest:node, + confirmed:node.error===null?node:previous[node.serverId]?.confirmed??null, + }])) +} diff --git a/shared/api/certificates.ts b/shared/api/certificates.ts index 2f8f5928..930f1b8e 100644 --- a/shared/api/certificates.ts +++ b/shared/api/certificates.ts @@ -1,3 +1,4 @@ +import { certificatesStateSchema } from "@mmapp/contracts/certificates" import type { AcmeCloudflareSettingsDto, CertificateIssueJob, @@ -77,3 +78,8 @@ export async function putCertificateRenewSettings( body: JSON.stringify(payload), }) } + +export async function readCertificatesState(baseUrl: string, options?: RequestInit) { + const signal = AbortSignal.any([...(options?.signal ? [options.signal] : []), AbortSignal.timeout(30_000)]) + return certificatesStateSchema.parse(await requestJson(baseUrl, "/api/certificates/state", { ...options, method: "GET", signal })) +}