From af5dbb05194234e986c52706cb3901bcd379f694 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 3 Oct 2026 14:45:51 +0700 Subject: [PATCH] =?UTF-8?q?fix(settings):=20=D0=B2=D0=BE=D1=81=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=B0=D0=B2=D0=BB=D0=B8=D0=B2=D0=B0=D1=82=D1=8C=20?= =?UTF-8?q?=D0=B1=D0=B0=D0=B7=D1=83=20=D1=82=D1=80=D0=B0=D0=BD=D0=B7=D0=B0?= =?UTF-8?q?=D0=BA=D1=86=D0=B8=D0=BE=D0=BD=D0=BD=D0=BE=20=D0=B8=20=D1=81?= =?UTF-8?q?=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D1=8F=D1=82=D1=8C=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=D1=83=D0=BB=D1=8C=D1=82=D0=B0=D1=82=20=D0=BE=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/settings/page.tsx | 7 ++- backend/package.json | 5 ++- backend/src/routes/system-database.ts | 4 +- .../src/services/system-database-backup.ts | 43 ++++++------------- .../system-database-restore-execution.test.ts | 9 ++++ .../system-database-restore-execution.ts | 23 ++++++++++ components/file-import-dialog.tsx | 10 +++-- docs/ux-ui-plan.md | 2 + docs/ux-ui-progress.md | 10 ++++- docs/ux-ui-stage-11b.md | 2 + shared/api/system-database.ts | 2 +- tests/e2e/system-database-receipt.spec.ts | 9 ++++ 12 files changed, 86 insertions(+), 40 deletions(-) create mode 100644 backend/src/services/system-database-restore-execution.test.ts create mode 100644 backend/src/services/system-database-restore-execution.ts create mode 100644 tests/e2e/system-database-receipt.spec.ts diff --git a/app/(main)/settings/page.tsx b/app/(main)/settings/page.tsx index 4752d400..ac7a3ebe 100644 --- a/app/(main)/settings/page.tsx +++ b/app/(main)/settings/page.tsx @@ -77,7 +77,7 @@ function DatabaseRestoreConfirm({ { event.preventDefault(); onConfirm() }} > {busy ? : null} {busy ? "Восстановление…" : "Восстановить"} @@ -124,8 +124,9 @@ export default function SettingsPage() { if (!dbRestoreFile || !systemDbAvailable) return setDbRestoreBusy(true) try { - await restoreSystemDatabaseBackup(backendUrl, dbRestoreFile) + const receipt = await restoreSystemDatabaseBackup(backendUrl, dbRestoreFile) toast.success("База приложения восстановлена") + for (const warning of [receipt.eventError,receipt.listenerError,receipt.schedulerError,receipt.cleanupError]) if (warning) toast.warning(warning) setDbRestoreFile(null) } catch (e) { toast.error(e instanceof Error ? e.message : "Не удалось восстановить базу") @@ -347,6 +348,8 @@ export default function SettingsPage() { open={dbRestoreDialogOpen} onOpenChange={setDbRestoreDialogOpen} title="Восстановление базы данных" + actionLabel="Продолжить" + successMessage={null} description="Выберите файл pg_dump custom (.dump) — текущая база будет полностью заменена" accept=".dump,.backup,application/octet-stream" onImport={async (files) => { diff --git a/backend/package.json b/backend/package.json index c7ec8144..53b40a63 100644 --- a/backend/package.json +++ b/backend/package.json @@ -27,14 +27,15 @@ "test:scheduler": "tsx src/services/scheduler-recorded-run.test.ts && tsx src/routes/scheduler.test.ts", "test:backups": "tsx src/services/backup-scheduler-run.test.ts && tsx src/services/backup-restore.test.ts && tsx src/services/backup-deletion.test.ts && tsx src/services/backup-dto.test.ts && tsx src/services/backup-storage-update.test.ts && tsx src/services/backup-job-execution.test.ts && tsx src/routes/backups-operations.test.ts && 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:scheduler && 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 && npm run test:netflow-settings && npm run test:netflow-purge && npm run test:evolution", + "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:scheduler && 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 && npm run test:netflow-settings && npm run test:netflow-purge && npm run test:evolution && npm run test:system-database", "test:evolution": "tsx src/services/evobgp-catalog.test.ts && tsx src/routes/evobgp-catalog.test.ts && tsx src/routes/evobgp-settings.test.ts && tsx src/plugins/auth.evolution-settings.test.ts", "test:netflow-purge": "tsx src/db/operation-lock.test.ts && tsx src/services/traffic-flow-purge-execution.test.ts && tsx src/routes/traffic-flow-purge.test.ts", "test:netflow-settings": "tsx src/services/traffic-flow-host-file-builders.test.ts && tsx src/services/traffic-flow-key-execution.test.ts && tsx src/routes/traffic-flow-settings.test.ts && tsx src/plugins/auth.netflow-settings.test.ts", "test:geoip": "tsx src/services/traffic-flow-geoip.test.ts && tsx src/plugins/auth.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-execution.test.ts && 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" + "test:certificates": "tsx src/services/certificate-issue-execution.test.ts && 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", + "test:system-database": "tsx src/services/system-database-restore-execution.test.ts" }, "dependencies": { "@aws-sdk/client-s3": "^3.888.0", diff --git a/backend/src/routes/system-database.ts b/backend/src/routes/system-database.ts index 143735a7..8382bc7d 100644 --- a/backend/src/routes/system-database.ts +++ b/backend/src/routes/system-database.ts @@ -45,7 +45,7 @@ const systemDatabaseRoutes: FastifyPluginAsync = async (app) => { return reply.status(400).send({ error: "Ожидается тело запроса с файлом pg_dump (custom, PGDMP)" }) } try { - await restoreSystemDatabaseBackup(body) + const outcome = await restoreSystemDatabaseBackup(body) await appendEvent({ level: "warning", eventType: "system.database.restore", @@ -58,7 +58,7 @@ const systemDatabaseRoutes: FastifyPluginAsync = async (app) => { sizeBytes: body.length, }, }) - return reply.send({ ok: true }) + return reply.send({ ok: true, ...outcome }) } catch (err) { const message = err instanceof Error ? err.message : String(err) const status = message.includes("уже выполняется") ? 409 : 400 diff --git a/backend/src/services/system-database-backup.ts b/backend/src/services/system-database-backup.ts index e16e99cd..5efa784f 100644 --- a/backend/src/services/system-database-backup.ts +++ b/backend/src/services/system-database-backup.ts @@ -3,6 +3,7 @@ import { spawn } from "node:child_process" import { mkdir, rm, writeFile } from "node:fs/promises" import os from "node:os" import path from "node:path" +import { executeSystemDatabaseRestore, validateSystemDatabaseRestoreArchive, type SystemDatabaseRestoreOutcome } from "./system-database-restore-execution.js" import { env } from "../config.js" import { withAdvisoryLock } from "../db/index.js" import { refreshScheduler, stopScheduler } from "./scheduler.js" @@ -11,8 +12,6 @@ import { stopTrafficFlowListener, } from "./traffic-flow-ingest.js" -const PG_DUMP_MAGIC = Buffer.from("PGDMP") -const MAX_RESTORE_BYTES = 512 * 1024 * 1024 const BACKUP_LOCK_KEY = 8_723_102 function fmtTimestamp(date = new Date()): string { @@ -20,15 +19,6 @@ function fmtTimestamp(date = new Date()): string { return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}_${pad(date.getHours())}-${pad(date.getMinutes())}-${pad(date.getSeconds())}` } -function assertPgDumpFile(buffer: Buffer): void { - if (buffer.length < PG_DUMP_MAGIC.length) { - throw new Error("Файл слишком маленький для дампа PostgreSQL") - } - if (!buffer.subarray(0, PG_DUMP_MAGIC.length).equals(PG_DUMP_MAGIC)) { - throw new Error("Файл не похож на резервную копию PostgreSQL (ожидается формат custom, PGDMP)") - } -} - function spawnCollected(command: string, args: string[], opts?: { stdin?: Buffer }): Promise { return new Promise((resolve, reject) => { const child = spawn(command, args, { @@ -56,7 +46,7 @@ function spawnCollected(command: string, args: string[], opts?: { stdin?: Buffer async function withDatabaseOperation(fn: () => Promise): Promise { return await withAdvisoryLock(BACKUP_LOCK_KEY, async () => { - stopTrafficFlowListener() + await stopTrafficFlowListener() stopScheduler() try { return await fn() @@ -77,21 +67,16 @@ export async function exportSystemDatabaseBackup(): Promise<{ filename: string; }) } -export async function restoreSystemDatabaseBackup(buffer: Buffer): Promise { - if (buffer.length > MAX_RESTORE_BYTES) { - throw new Error("Файл бэкапа слишком большой") - } - assertPgDumpFile(buffer) - - await withDatabaseOperation(async () => { - const tempDir = path.join(os.tmpdir(), "mmapp-db-restore") - await mkdir(tempDir, { recursive: true }) - const tempPath = path.join(tempDir, `restore-${randomUUID()}.dump`) - try { - await writeFile(tempPath, buffer) - await spawnCollected("pg_restore", ["--clean", "--if-exists", "-d", env.DATABASE_URL, tempPath]) - } finally { - await rm(tempPath, { force: true }) - } - }) +export async function restoreSystemDatabaseBackup(buffer: Buffer): Promise { + validateSystemDatabaseRestoreArchive(buffer) + return await withDatabaseOperation(() => executeSystemDatabaseRestore(buffer, env.DATABASE_URL, { + allocate: async () => { + const tempDir = path.join(os.tmpdir(), "mmapp-db-restore") + await mkdir(tempDir, { recursive: true }) + return path.join(tempDir, `restore-${randomUUID()}.dump`) + }, + write: (file, data) => writeFile(file, data), + run: async args => { await spawnCollected("pg_restore", args) }, + remove: file => rm(file, { force: true }), + })) } diff --git a/backend/src/services/system-database-restore-execution.test.ts b/backend/src/services/system-database-restore-execution.test.ts new file mode 100644 index 00000000..f1fcf48e --- /dev/null +++ b/backend/src/services/system-database-restore-execution.test.ts @@ -0,0 +1,9 @@ +import assert from "node:assert/strict" +import {test} from "node:test" +import {executeSystemDatabaseRestore} from "./system-database-restore-execution.js" +const dump=Buffer.from("PGDMPfixture") +function fixture(){const calls:string[]=[];let failWrite=false,failRun=false,failRemove=false;const deps={allocate:async()=>{calls.push("allocate");return "fixture.dump"},write:async()=>{calls.push("write");if(failWrite)throw Error("write failed")},run:async(args:string[])=>{calls.push("run");assert.ok(args.includes("--single-transaction"));assert.deepEqual(args.slice(-3),["-d","postgres://fixture","fixture.dump"]);if(failRun)throw Error("restore failed")},remove:async()=>{calls.push("remove");if(failRemove)throw Error("cleanup failed")}};return {calls,deps,setWrite:()=>failWrite=true,setRun:()=>failRun=true,setRemove:()=>failRemove=true}} +test("invalid archive never allocates or runs command",async()=>{const f=fixture();for(const data of [Buffer.alloc(0),Buffer.from("sqlite")])await assert.rejects(executeSystemDatabaseRestore(data,"postgres://fixture",f.deps),/PGDMP/);assert.deepEqual(f.calls,[])}) +test("restore runs one transactional command and cleans file after success",async()=>{const f=fixture();assert.deepEqual(await executeSystemDatabaseRestore(dump,"postgres://fixture",f.deps),{});assert.deepEqual(f.calls,["allocate","write","run","remove"])}) +test("write or restore failure always cleans temporary file and never retries",async()=>{for(const step of ["write","run"]){const f=fixture();if(step==="write")f.setWrite();else f.setRun();await assert.rejects(executeSystemDatabaseRestore(dump,"postgres://fixture",f.deps),/failed/);assert.deepEqual(f.calls,step==="write"?["allocate","write","remove"]:["allocate","write","run","remove"])}}) +test("cleanup failure keeps confirmed restore result; main failure is preserved",async()=>{const f=fixture();f.setRemove();assert.deepEqual(await executeSystemDatabaseRestore(dump,"postgres://fixture",f.deps),{cleanupError:"Не удалось удалить временный файл восстановления"});const bad=fixture();bad.setRun();bad.setRemove();await assert.rejects(executeSystemDatabaseRestore(dump,"postgres://fixture",bad.deps),/restore failed; Не удалось удалить/)}) diff --git a/backend/src/services/system-database-restore-execution.ts b/backend/src/services/system-database-restore-execution.ts new file mode 100644 index 00000000..f411937b --- /dev/null +++ b/backend/src/services/system-database-restore-execution.ts @@ -0,0 +1,23 @@ +export type SystemDatabaseRestoreOutcome={cleanupError?:string} +interface Dependencies { + allocate:()=>Promise + write:(path:string,buffer:Buffer)=>Promise + run:(args:string[])=>Promise + remove:(path:string)=>Promise +} +export function validateSystemDatabaseRestoreArchive(buffer:Buffer):void{ + if(buffer.length>512*1024*1024)throw new Error("Файл бэкапа слишком большой") + if(buffer.length<5||!buffer.subarray(0,5).equals(Buffer.from("PGDMP")))throw new Error("Файл не похож на резервную копию PostgreSQL (PGDMP)") +} +export async function executeSystemDatabaseRestore(buffer:Buffer,databaseUrl:string,deps:Dependencies):Promise{ + validateSystemDatabaseRestoreArchive(buffer) + const file=await deps.allocate() + let failed=false,failure:unknown,cleanupError:string|undefined + try{ + await deps.write(file,buffer) + await deps.run(["--single-transaction","--clean","--if-exists","-d",databaseUrl,file]) + }catch(error){failed=true;failure=error} + finally{try{await deps.remove(file)}catch{cleanupError="Не удалось удалить временный файл восстановления"}} + if(failed){const message=failure instanceof Error?failure.message:"Восстановление PostgreSQL не подтверждено";throw new Error(message+(cleanupError?"; "+cleanupError:""))} + return cleanupError?{cleanupError}:{} +} diff --git a/components/file-import-dialog.tsx b/components/file-import-dialog.tsx index 7d29e752..3084d2c4 100644 --- a/components/file-import-dialog.tsx +++ b/components/file-import-dialog.tsx @@ -22,6 +22,8 @@ interface FileImportDialogProps { description?: string accept?: string multiple?: boolean + successMessage?: string | null + actionLabel?: string onImport: (files: File[]) => void | Promise } @@ -33,6 +35,8 @@ function FileImportDialog({ accept = "*", multiple = false, onImport, + successMessage = "Импорт выполнен", + actionLabel = "Импортировать", }: FileImportDialogProps) { const [importing, setImporting] = useState(false) @@ -58,13 +62,13 @@ function FileImportDialog({ await onImport(rawFiles) actions.clearFiles() onOpenChange(false) - toast.success("Импорт выполнен") + if (successMessage) toast.success(successMessage) } catch (err) { toast.error(err instanceof Error ? err.message : "Ошибка импорта") } finally { setImporting(false) } - }, [actions, files, onImport, onOpenChange]) + }, [actions, files, onImport, onOpenChange, successMessage]) return ( @@ -109,7 +113,7 @@ function FileImportDialog({ Отмена diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 621fac87..7f8b5c32 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -290,3 +290,5 @@ REST запрос → ожидание → подтверждённый output / Завершён 11Б.3.6.1.2: DataSource health token/revision/abort/ABA, explicit checking и validated health body. 2 unit, 10 source/health, 16 Settings/Evo provider и 8 Evo source E2E, typecheck/lint/build прошли; два test setup уточнены. System monitor остаётся отдельным reader. Далее 11Б.3.6.2.1 — system DB API/credentials in events; затем backend outcomes и UI. Завершён 11Б.3.6.2.1: system DB export PGDMP/leaf filename и restore strict receipt/limits/abort без повтора; DATABASE_URL удалён из новых event payload. 3 unit группы/typecheck/lint/backend и frontend builds прошли. UI/backend partial/restore transaction не приняты. Далее 11Б.3.6.2.2 — backend transaction/outcomes/DI HTTP/JWT, затем 11Б.3.6.2.3 форма. + +Завершён 11Б.3.6.2.2.1: DI restore executor/--single-transaction/cleanup warning, file choose без фиктивного success, confirmation до receipt. 4 DI/3 API/2 browser tests, backend/frontend builds/typecheck/lint прошли; реальный PG restore не запускался. System DB не принят полностью. Следующий точный шаг 11Б.3.6.2.2.2 — backend core/restart/event partial outcomes + DI HTTP/JWT/concurrency; затем 11Б.3.6.2.3 source/lock/unknown UI. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index e3941b40..373105b2 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -28,7 +28,7 @@ | 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 | | 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 | -| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.6.2.1; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings | +| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.6.2.2.1; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,14 @@ +## Блок 11Б.3.6.2.2.1 — выполнение восстановления PostgreSQL (2026-10-03) + +- Restore executor вынесен в pure DI helper. Production pg_restore получает --single-transaction вместе с --clean/--if-exists; validate size/PGDMP prefix выполняется до lock/остановки сборщиков и повторно в executor. Команда не повторяется, временный файл удаляется при write/run error. Cleanup failure после успешной команды — отдельный cleanupError, не отменяет её подтверждённый результат; основная ошибка не подменяется cleanup error. +- Backend receipt и frontend strict parser поддерживают cleanupError; Settings показывает предупреждение отдельно от успеха. File selection больше не сообщает «Импорт выполнен»: FileImportDialog получил optional actionLabel/successMessage, Settings использует «Продолжить» и suppress success. Остальные callers сохраняют прежние defaults. Confirmation preventDefault удерживает dialog до receipt; ошибка сохраняет выбранный файл. +- 4 DI executor tests, 3 API unit группы, 2 browser receipt сценария (false → нет успеха/dialog сохранён; true+cleanupError → success+warning), backend build/typecheck/профильный lint и production frontend/contracts build прошли. Profile test:system-database включён в backend test. +- Это проверка переданных аргументов/управления выполнением, не реальное доказательство PG rollback/совместимости архива. Настоящий PG restore/pg_dump не запускался; UDP worker stop acknowledgment не доказан. Внешний withDatabaseOperation restart/listener/scheduler и appendEvent всё ещё могут скрыть уже выполненный результат; остаётся source/lock/unknown review в форме. System DB целиком не принят. +- Следующий точный шаг 11Б.3.6.2.2.2: разделить результат core операции и failures restart listener/scheduler/event в backend, DI HTTP/JWT gates и запрет повторной конкурентной операции. Затем 11Б.3.6.2.3: отдельная форма DB с active/source guards, общим sync lock, unknown checkpoint и адаптивной приёмкой. Alerts/Releases/probe scheduling и итоговая 11Б/12 ещё впереди. + ## Блок 11Б.3.6.2.1 — system database API и журнал (2026-10-03) - Export reader проверяет PGDMP prefix/nonempty binary вместо принятия HTML/JSON, безопасное leaf filename (RFC5987/quoted/plain) и fallback .dump вместо .db. Restore проверяет размер ≤512 МБ, PGDMP prefix и caller abort до POST; после запроса нужен strict literal ok receipt, false/204/malformed/contradictory error не дают успеха. Нет автоматического повтора; 10 минут timeout/caller signal не отменяет серверное восстановление. diff --git a/docs/ux-ui-stage-11b.md b/docs/ux-ui-stage-11b.md index 513a7f5c..cdd6971c 100644 --- a/docs/ux-ui-stage-11b.md +++ b/docs/ux-ui-stage-11b.md @@ -222,3 +222,5 @@ ReUI PRO/base-nova; существующие Frame, Data Grid v9, KpiStatGrid, S Завершён 11Б.3.6.1.2: DataSource health token/revision/abort/ABA, explicit checking и validated health body. 2 unit, 10 source/health, 16 Settings/Evo provider и 8 Evo source E2E, typecheck/lint/build прошли; два test setup уточнены. System monitor остаётся отдельным reader. Далее 11Б.3.6.2.1 — system DB API/credentials in events; затем backend outcomes и UI. Завершён 11Б.3.6.2.1: system DB export PGDMP/leaf filename и restore strict receipt/limits/abort без повтора; DATABASE_URL удалён из новых event payload. 3 unit группы/typecheck/lint/backend и frontend builds прошли. UI/backend partial/restore transaction не приняты. Далее 11Б.3.6.2.2 — backend transaction/outcomes/DI HTTP/JWT, затем 11Б.3.6.2.3 форма. + +Завершён 11Б.3.6.2.2.1: DI restore executor/--single-transaction/cleanup warning, file choose без фиктивного success, confirmation до receipt. 4 DI/3 API/2 browser tests, backend/frontend builds/typecheck/lint прошли; реальный PG restore не запускался. System DB не принят полностью. Следующий точный шаг 11Б.3.6.2.2.2 — backend core/restart/event partial outcomes + DI HTTP/JWT/concurrency; затем 11Б.3.6.2.3 source/lock/unknown UI. diff --git a/shared/api/system-database.ts b/shared/api/system-database.ts index 6b5c5c03..78ed96a9 100644 --- a/shared/api/system-database.ts +++ b/shared/api/system-database.ts @@ -2,7 +2,7 @@ import {z} from "zod" import {ApiClientError,requestBlob,requestJson} from "@/shared/api/http-client" const MAX_RESTORE_BYTES=512*1024*1024 const magic=[80,71,68,77,80] -const receiptSchema=z.object({ok:z.literal(true),eventError:z.string().min(1).optional(),listenerError:z.string().min(1).optional(),schedulerError:z.string().min(1).optional()}).strict() +const receiptSchema=z.object({ok:z.literal(true),eventError:z.string().min(1).optional(),listenerError:z.string().min(1).optional(),schedulerError:z.string().min(1).optional(),cleanupError:z.string().min(1).optional()}).strict() export type SystemDatabaseRestoreReceipt=z.output type Options={signal?:AbortSignal} const signal=(options?:Options)=>options?.signal?AbortSignal.any([options.signal,AbortSignal.timeout(600_000)]):AbortSignal.timeout(600_000) diff --git a/tests/e2e/system-database-receipt.spec.ts b/tests/e2e/system-database-receipt.spec.ts new file mode 100644 index 00000000..6921c29b --- /dev/null +++ b/tests/e2e/system-database-receipt.spec.ts @@ -0,0 +1,9 @@ +import {test,expect} from "@playwright/test" +for(const confirmed of [false,true])test("System DB: choose is not restore; strict receipt and cleanup warning/"+confirmed,async({page})=>{ + let restores=0 + await page.route("**/health",r=>r.fulfill({json:{status:"ok"}}));await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;if(path==="/api/auth/config")return r.fulfill({json:{required:false}});if(path==="/api/system/database/restore"){restores++;return r.fulfill({json:confirmed?{ok:true,cleanupError:"temporary dump cleanup failed"}:{ok:false}})}return r.fulfill({status:503,json:{error:"outside database fixture"}})}) + await page.goto("/settings");await page.getByRole("button",{name:"Выбрать файл",exact:true}).click();await page.getByRole("dialog").locator('input[type="file"]').setInputFiles({name:"fixture.dump",mimeType:"application/octet-stream",buffer:Buffer.from("PGDMPfixture")});await page.getByRole("button",{name:"Продолжить",exact:true}).click();await expect(page.getByRole("alertdialog")).toBeVisible();await expect(page.getByText("Импорт выполнен",{exact:true})).toHaveCount(0);expect(restores).toBe(0) + await page.getByRole("alertdialog").getByRole("button",{name:"Восстановить",exact:true}).click() + if(confirmed){await expect(page.getByText("База приложения восстановлена",{exact:true})).toBeVisible();await expect(page.getByText("temporary dump cleanup failed",{exact:true})).toBeVisible();await expect(page.getByRole("alertdialog")).toHaveCount(0)}else{await expect(page.getByText("Восстановление базы не подтверждено сервером",{exact:true})).toBeVisible();await expect(page.getByText("База приложения восстановлена",{exact:true})).toHaveCount(0);await expect(page.getByRole("alertdialog")).toBeVisible()} + expect(restores).toBe(1) +})