fix(wireguard): очищать подтверждённые объекты при отказе импорта

This commit is contained in:
Denozordec committed 2026-10-09 09:04:05 +07:00
1 parent f9980d7d7e
commit e01454b720
10 files changed
+331 -58

No files matched your search

+1 -1
View File
@@ -18,7 +18,7 @@
"test:probes": "tsx src/routes/probes.test.ts && tsx src/routes/probes.cancel.test.ts", "test:probes": "tsx src/routes/probes.test.ts && tsx src/routes/probes.cancel.test.ts",
"test:exec": "tsx src/routes/exec.test.ts && tsx src/plugins/auth.exec.test.ts", "test:exec": "tsx src/routes/exec.test.ts && tsx src/plugins/auth.exec.test.ts",
"test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts && tsx src/plugins/auth.ospf-optimizer.test.ts", "test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts && tsx src/plugins/auth.ospf-optimizer.test.ts",
"test:wireguard": "npx tsx src/services/wireguard-config.test.ts && npx tsx --test src/services/wireguard-import.test.ts && npx tsx src/routes/wireguard-import.test.ts", "test:wireguard": "npx tsx src/services/wireguard-config.test.ts && npx tsx --test src/services/wireguard-import.test.ts src/services/wireguard-import-apply.test.ts && npx tsx src/routes/wireguard-import.test.ts",
"test:traffic-rate": "tsx src/services/traffic-rate.test.ts", "test:traffic-rate": "tsx src/services/traffic-rate.test.ts",
"test:traffic-flow": "tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-ifindex.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-planes.test.ts && tsx src/services/traffic-flow-ip.test.ts && tsx src/services/traffic-flow-dest.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts && tsx src/services/traffic-flow-map-hops.test.ts && tsx src/services/traffic-flow-purge.test.ts && tsx src/services/traffic-flow-geoip.test.ts && tsx src/services/traffic-flow-facts.test.ts && tsx src/services/traffic-flow-facts-filter.test.ts && tsx src/services/traffic-flow-facts-rebuild.test.ts && tsx src/services/statistics-aggregate.test.ts", "test:traffic-flow": "tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-ifindex.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-planes.test.ts && tsx src/services/traffic-flow-ip.test.ts && tsx src/services/traffic-flow-dest.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts && tsx src/services/traffic-flow-map-hops.test.ts && tsx src/services/traffic-flow-purge.test.ts && tsx src/services/traffic-flow-geoip.test.ts && tsx src/services/traffic-flow-facts.test.ts && tsx src/services/traffic-flow-facts-filter.test.ts && tsx src/services/traffic-flow-facts-rebuild.test.ts && tsx src/services/statistics-aggregate.test.ts",
"test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts && tsx src/modules/users/interface-inventory.test.ts && tsx src/routes/users-catalog.test.ts", "test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts && tsx src/modules/users/interface-inventory.test.ts && tsx src/routes/users-catalog.test.ts",
+11 -53
View File
@@ -12,6 +12,7 @@ import {
} from "@mmapp/contracts/wireguard" } from "@mmapp/contracts/wireguard"
import { MikrotikClient, MikrotikError } from "../services/mikrotik.js" import { MikrotikClient, MikrotikError } from "../services/mikrotik.js"
import { deleteWireguardInterface } from "../services/wireguard-delete.js" import { deleteWireguardInterface } from "../services/wireguard-delete.js"
import { applyWireguardImport, WireguardImportApplyError } from "../services/wireguard-import-apply.js"
import { parseWireguardImport } from "../services/wireguard-import.js" import { parseWireguardImport } from "../services/wireguard-import.js"
import { import {
generateMikrotikRsc, generateMikrotikRsc,
@@ -98,51 +99,6 @@ function previewFromParsed(parsed: WgParsedConfig) {
} }
} }
async function applyParsedConfig(
client: MikrotikClient,
parsed: WgParsedConfig,
): Promise<{ interfaceName: string; peersCreated: number }> {
const name = parsed.interface.name
const ifaceBody = toRosBody({
name,
"listen-port": String(parsed.interface.listenPort ?? 13231),
mtu: String(parsed.interface.mtu ?? 1420),
"private-key": parsed.interface.privateKey,
comment: parsed.interface.comment,
disabled: parsed.interface.disabled ? "yes" : undefined,
})
await putWireguardInterface(client, ifaceBody)
if (parsed.interface.address) {
await putIpAddress(client, parsed.interface.address, name)
}
let peersCreated = 0
for (const p of parsed.peers) {
if (!p.publicKey) continue
await putWireguardPeer(
client,
peerToRosBody({
interfaceName: name,
publicKey: p.publicKey,
allowedAddresses: p.allowedAddresses,
endpointAddress: p.endpointAddress,
endpointPort: p.endpointPort,
persistentKeepalive: p.persistentKeepalive,
comment: p.comment,
name: p.name,
privateKey: p.privateKey,
clientAddress: p.clientAddress,
clientDns: p.clientDns,
clientEndpoint: p.clientEndpoint,
disabled: p.disabled,
}),
)
peersCreated += 1
}
return { interfaceName: name, peersCreated }
}
function findIface( function findIface(
list: WgIfaceDto[], list: WgIfaceDto[],
serverId: string, serverId: string,
@@ -358,16 +314,18 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
if (!server) return reply.status(404).send({ error: "Сервер не найден" }) if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server) const client = MikrotikClient.fromServer(server)
try { try {
const existing = await client.get<Array<{ name?: string }>>("/interface/wireguard") const applied = await applyWireguardImport(client, config, (peer, interfaceName) => peerToRosBody({ ...peer, interfaceName }))
if (existing.some(iface => iface.name === config.interface.name)) { try {
return reply.status(409).send({ error: "Интерфейс с таким именем уже существует. Укажите другое имя в конфигурации", preview }) await recordWireguard(server, "copy")
} catch {
return reply.status(502).send({ error: "Конфигурация применена, но ревизия не сохранена. Обновите данные перед повтором", state: "applied", preview })
} }
const applied = await applyParsedConfig(client, config)
await recordWireguard(server, "copy")
return reply.send({ dryRun: false, preview, applied }) return reply.send({ dryRun: false, preview, applied })
} catch (e) { } catch (error) {
const msg = e instanceof Error ? e.message : String(e) if (error instanceof WireguardImportApplyError) {
return reply.status(502).send({ error: `RouterOS: ${msg}`, preview }) return reply.status(error.state === "conflict" ? 409 : 502).send({ error: error.message, state: error.state, preview })
}
return reply.status(502).send({ error: "Не удалось проверить состояние WireGuard перед импортом; применение не начато", preview })
} }
}) })
@@ -0,0 +1,143 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { MikrotikError } from "./mikrotik.js"
import { applyWireguardImport, WireguardImportApplyError } from "./wireguard-import-apply.js"
import type { WgParsedConfig } from "./wireguard-config.js"
const iface = "/interface/wireguard", addresses = "/ip/address", peers = "/interface/wireguard/peers"
type Row = Record<string, string>
const parsed: WgParsedConfig = { format: "conf", interface: { name: "own-wg", address: "198.18.255.241/30" }, peers: [{ publicKey: "peer-one", allowedAddresses: ["198.18.255.242/32"] }, { publicKey: "peer-two", allowedAddresses: ["198.18.255.243/32"] }] }
const body = (p: WgParsedConfig["peers"][number], name: string) => ({ interface: name, "public-key": p.publicKey, "allowed-address": p.allowedAddresses.join(",") })
function fixture() {
const rows: Record<string, Row[]> = {
[iface]: [{ ".id": "*F", name: "foreign-wg" }],
[addresses]: [{ ".id": "*A", interface: "foreign-wg", address: "192.0.2.1/24" }],
[peers]: [{ ".id": "*P", interface: "foreign-wg", "public-key": "foreign-peer" }],
}
const baseline = structuredClone(rows), deletes: string[] = []
let next = 0, putCount = 0
const client = {
async get<T>(path: string): Promise<T> { return structuredClone(rows[path]) as T },
async put<T>(path: string, value: Record<string, string>): Promise<T> {
putCount++
const row = { ".id": "*OWN/" + (++next), ...value }
rows[path].push(row)
return structuredClone(row) as T
},
async delete(path: string): Promise<void> {
deletes.push(path)
const split = path.lastIndexOf("/"), base = path.slice(0, split), id = decodeURIComponent(path.slice(split + 1))
rows[base] = rows[base].filter(row => row[".id"] !== id)
},
}
return { client, rows, baseline, deletes, puts: () => putCount }
}
const state = (expected: WireguardImportApplyError["state"]) => (error: unknown) => error instanceof WireguardImportApplyError && error.state === expected
test("baseline read failure prevents every mutation", async () => {
const f = fixture()
f.client.get = async () => { throw new Error("Read unavailable") }
await assert.rejects(applyWireguardImport(f.client, parsed, body), /Read unavailable/)
assert.equal(f.puts(), 0); assert.deepEqual(f.deletes, [])
})
test("success cannot be reported when receipt identities do not match native state", async () => {
const f = fixture(), put = f.client.put
f.client.put = async (path, value) => {
const result = await put(path, value)
if (path === iface) f.rows[iface].find(row => row.name === "own-wg")!.name = "different-native-name"
return result
}
await assert.rejects(applyWireguardImport(f.client, parsed, body), state("needs-review"))
assert.deepEqual(f.deletes, [])
})
test("success requires native receipt identities and repeat conflicts without mutations", async () => {
const f = fixture()
assert.deepEqual(await applyWireguardImport(f.client, parsed, body), { interfaceName: "own-wg", peersCreated: 2 })
const before = structuredClone(f.rows), puts = f.puts()
await assert.rejects(applyWireguardImport(f.client, parsed, body), state("conflict"))
assert.deepEqual(f.rows, before); assert.equal(f.puts(), puts); assert.deepEqual(f.deletes, [])
})
test("second peer rejection removes only receipt-owned objects, then retry succeeds", async () => {
const f = fixture(), put = f.client.put
f.client.put = async (path, value) => {
if (value["public-key"] === "peer-two") throw new MikrotikError(400, peers, "Rejected secret payload must not be exposed")
return put(path, value)
}
await assert.rejects(applyWireguardImport(f.client, parsed, body), error => state("rolled-back")(error) && !(error as Error).message.includes("secret payload"))
assert.deepEqual(f.rows, f.baseline)
assert.deepEqual(f.deletes, [peers + "/*OWN%2F3", addresses + "/*OWN%2F2", iface + "/*OWN%2F1"])
f.client.put = put
assert.equal((await applyWireguardImport(f.client, parsed, body)).peersCreated, 2)
})
test("address rejection removes the interface, preserves foreign objects", async () => {
const f = fixture(), put = f.client.put
f.client.put = async (path, value) => { if (path === addresses) throw new MikrotikError(400, path, "rejected"); return put(path, value) }
await assert.rejects(applyWireguardImport(f.client, parsed, body), state("rolled-back"))
assert.deepEqual(f.rows, f.baseline)
})
for (const path of [iface, addresses, peers]) {
test(`lost response after committed ${path} preserves unknown outcome for review`, async () => {
const f = fixture(), put = f.client.put
f.client.put = async (target, value) => { const receipt = await put(target, value); if (target === path) throw new Error("Connection reset after commit"); return receipt }
await assert.rejects(applyWireguardImport(f.client, parsed, body), state("needs-review"))
assert.deepEqual(f.deletes, [])
})
}
test("missing create receipt never permits cleanup by interface name", async () => {
const f = fixture(), put = f.client.put
f.client.put = async (path, value) => { await put(path, value); return undefined as never }
await assert.rejects(applyWireguardImport(f.client, parsed, body), state("needs-review"))
assert.deepEqual(f.deletes, [])
})
for (const status of [408, 500]) {
test(`HTTP ${status} after commit does not imply a rejected mutation`, async () => {
const f = fixture(), put = f.client.put
f.client.put = async (path, value) => { await put(path, value); throw new MikrotikError(status, path, "Uncertain upstream response") }
await assert.rejects(applyWireguardImport(f.client, parsed, body), state("needs-review"))
assert.deepEqual(f.deletes, [])
})
}
test("receipt referring to baseline identity never permits foreign deletion", async () => {
const f = fixture()
f.client.put = async () => ({ ".id": "*F" }) as never
await assert.rejects(applyWireguardImport(f.client, parsed, body), state("needs-review"))
assert.deepEqual(f.rows, f.baseline); assert.deepEqual(f.deletes, [])
})
test("concurrent foreign child stops compensation before any deletion", async () => {
const f = fixture(), put = f.client.put
f.client.put = async (path, value) => {
if (path === peers) { f.rows[peers].push({ ".id": "*CONCURRENT", interface: "own-wg", "public-key": "foreign" }); throw new MikrotikError(400, path, "rejected") }
return put(path, value)
}
await assert.rejects(applyWireguardImport(f.client, parsed, body), state("needs-review"))
assert.deepEqual(f.deletes, [])
})
test("changed native identity stops compensation before deletion", async () => {
const f = fixture(), put = f.client.put
f.client.put = async (path, value) => {
if (path === peers) { f.rows[iface].find(row => row.name === "own-wg")!.name = "changed-elsewhere"; throw new MikrotikError(400, path, "rejected") }
return put(path, value)
}
await assert.rejects(applyWireguardImport(f.client, parsed, body), state("needs-review"))
assert.deepEqual(f.deletes, [])
})
for (const retain of [false, true]) {
test(`cleanup ${retain ? "unconfirmed" : "rejected"} is never reported as rolled back`, async () => {
const f = fixture(), put = f.client.put
f.client.put = async (path, value) => { if (path === peers) throw new MikrotikError(400, path, "rejected"); return put(path, value) }
f.client.delete = async () => { if (!retain) throw new Error("Delete rejected") }
await assert.rejects(applyWireguardImport(f.client, parsed, body), state("needs-review"))
})
}
@@ -0,0 +1,86 @@
import { MikrotikError, type MikrotikClient } from "./mikrotik.js"
import type { WgParsedConfig, WgParsedPeer } from "./wireguard-config.js"
import { asRosArray, toRosBody } from "./wireguard-ros.js"
type Client = Pick<MikrotikClient, "get" | "put" | "delete">
type Row = Record<string, string>
const paths = ["/interface/wireguard", "/ip/address", "/interface/wireguard/peers"] as const
type Created = { path: typeof paths[number]; id: string; identity: Record<string, string> }
export class WireguardImportApplyError extends Error {
constructor(public readonly state: "conflict" | "rolled-back" | "needs-review") {
super(state === "conflict"
? "Интерфейс с таким именем уже существует. Укажите другое имя в конфигурации"
: state === "rolled-back"
? "RouterOS отклонил импорт. Созданные этим запросом объекты удалены; исправьте конфигурацию и повторите"
: "Не удалось подтвердить результат импорта или очистки. Проверьте интерфейс на роутере перед повтором")
}
}
/** Receipts establish ownership; a lost response never authorizes deletion by name alone. */
export async function applyWireguardImport(
client: Client,
parsed: WgParsedConfig,
peerBody: (peer: WgParsedPeer, interfaceName: string) => Record<string, string>,
): Promise<{ interfaceName: string; peersCreated: number }> {
const name = parsed.interface.name
const read = async () => Object.fromEntries(await Promise.all(paths.map(async path => [path, asRosArray<Row>(await client.get(path))]))) as Record<typeof paths[number], Row[]>
const baseline = await read()
if (baseline[paths[0]].some(row => row.name === name)) throw new WireguardImportApplyError("conflict")
const created: Created[] = []
let unconfirmed = false
const owns = (row: Row | undefined, item: Created) => !!row && Object.entries(item.identity).every(([key, value]) => row[key] === value)
const put = async (path: typeof paths[number], body: Record<string, string>, identity: Record<string, string>) => {
let receipt: unknown
try {
receipt = await client.put(path, body)
} catch (error) {
// Only an explicit RouterOS rejection permits immediate compensation.
unconfirmed = !(error instanceof MikrotikError && [400, 401, 403, 404, 405, 409, 422].includes(error.statusCode))
throw error
}
const rows = asRosArray<Row>(receipt)
const id = rows.length === 1 ? rows[0]?.[".id"] : undefined
if (typeof id !== "string" || !id || baseline[path].some(row => row[".id"] === id)) {
unconfirmed = true
throw new Error("Unconfirmed create receipt")
}
created.push({ path, id, identity })
}
try {
await put(paths[0], toRosBody({ name, "listen-port": String(parsed.interface.listenPort ?? 13231), mtu: String(parsed.interface.mtu ?? 1420), "private-key": parsed.interface.privateKey, comment: parsed.interface.comment, disabled: parsed.interface.disabled ? "yes" : undefined }), { name })
if (parsed.interface.address) {
await put(paths[1], { address: parsed.interface.address, interface: name }, { address: parsed.interface.address, interface: name })
}
for (const peer of parsed.peers) {
await put(paths[2], peerBody(peer, name), { interface: name, "public-key": peer.publicKey })
}
const current = await read()
if (created.some(item => !owns(current[item.path].find(row => row[".id"] === item.id), item))) {
unconfirmed = true
throw new Error("Native identities not confirmed")
}
return { interfaceName: name, peersCreated: parsed.peers.length }
} catch {
if (unconfirmed) throw new WireguardImportApplyError("needs-review")
try {
const current = await read()
const ifaceId = created.find(item => item.path === paths[0])?.id
// A concurrent foreign child must never be removed with the interface.
if (paths.slice(1).some(path => current[path].some(row => (row.interface === name || row.interface === ifaceId) && !created.some(item => item.path === path && item.id === row[".id"])))
|| created.some(item => !owns(current[item.path].find(row => row[".id"] === item.id), item))) {
throw new Error("Ownership not confirmed")
}
for (const item of [...created].reverse()) await client.delete(`${item.path}/${encodeURIComponent(item.id)}`)
const after = await read()
if (created.some(item => after[item.path].some(row => row[".id"] === item.id))
|| after[paths[0]].some(row => row.name === name)
|| paths.slice(1).some(path => after[path].some(row => row.interface === name || row.interface === ifaceId))) {
throw new Error("Cleanup not confirmed")
}
} catch {
throw new WireguardImportApplyError("needs-review")
}
throw new WireguardImportApplyError("rolled-back")
}
}
+14
View File
@@ -583,3 +583,17 @@ Native checks подтверждают name,ключевую идентично
Все собственные объекты удалены; выбранные WG/peers/IP/routes/IPv4 filter/nat/mangle совпали с baseline на обоих CHR. Для imported conf без comment ownership подтверждалась уникальным fixture name и ожидаемой native public identity, используемой только в памяти. Остались3 основные службы, backend healthy. DB controls до/после:servers2,speedHistory9,retentionDays14,migrations9. Audit revisions намеренно сохранены. Production smoke /dashboard,/servers,/uptime?tab=speed,/settings,/filters,/wireguard →HTTP200,320px fits,0 page/API errors. Публичные JSON проверены на отсутствие ключей/конфигов/паролей. Дополнительная общая локальная регрессия не повторялась после уже проверенного69; production build/typecheck и реальные scoped сценарии выполнены. Все собственные объекты удалены; выбранные WG/peers/IP/routes/IPv4 filter/nat/mangle совпали с baseline на обоих CHR. Для imported conf без comment ownership подтверждалась уникальным fixture name и ожидаемой native public identity, используемой только в памяти. Остались3 основные службы, backend healthy. DB controls до/после:servers2,speedHistory9,retentionDays14,migrations9. Audit revisions намеренно сохранены. Production smoke /dashboard,/servers,/uptime?tab=speed,/settings,/filters,/wireguard →HTTP200,320px fits,0 page/API errors. Публичные JSON проверены на отсутствие ключей/конфигов/паролей. Дополнительная общая локальная регрессия не повторялась после уже проверенного69; production build/typecheck и реальные scoped сценарии выполнены.
Следующий12.5.71 — partial apply failures/компенсация и повторный import. Concurrent collision races, rollback/interrupted mutation, неизвестные/повторные поляconf,PSK/multi-address поддержка, ready Peer client template import,private-key UI download,IPv6/throughput/RBAC/multi-instance/Telegram/Portal и остальные внешние протоколы/интеграции остаются открытыми. Локальный коммит без push. Следующий12.5.71 — partial apply failures/компенсация и повторный import. Concurrent collision races, rollback/interrupted mutation, неизвестные/повторные поляconf,PSK/multi-address поддержка, ready Peer client template import,private-key UI download,IPv6/throughput/RBAC/multi-instance/Telegram/Portal и остальные внешние протоколы/интеграции остаются открытыми. Локальный коммит без push.
## 12.5.71 — подтверждённая очистка частичного импорта WireGuard (2026-10-09)
Локально обнаружено по code: прежний последовательный apply оставлял интерфейс/IP, если поздний peer PUT отклонялся. Добавлен backend/src/services/wireguard-import-apply.ts; route использует сервис вместо встроенного applyParsedConfig. До mutations читаются interface/IP/peer baseline; занятое имя возвращает409. Каждый успешный create должен дать один строковый ROS .id, отсутствовавший в baseline; identity записывается в памяти. Native GET после всех PUT подтверждает созданные ID и name/interface/public-key/address association перед success receipt. Полное сравнение всех настроек/связности остаётся внешней проверкой, не подменяется этим identity check.
При подтверждённом RouterOS rejection (явные400/401/403/404/405/409/422) сервис читает текущее состояние, проверяет принадлежность всех receipt-owned объектов и отсутствие чужих связанных объектов. Только затем удаляет собственные peers →IP →interface в обратном порядке и перечитывает ресурсы. Подтверждённое отсутствие объектов →502,state rolled-back с понятным сообщением и возможностью исправить/повторить. Удаление существующих baseline IDs, чужих child rows и cleanup только по имени запрещены. Это компенсация текущего запроса, не RouterOS транзакция.
Потеря ответа после commit,HTTP408/500,неполный create receipt или несовпадение native identity →needs-review; автоматическое удаление не выполняется при неподтверждённом create outcome. Ошибка cleanup/read/изменённая identity/чужой concurrent child →needs-review, успешный rollback не обещается. Если DELETE оборвался после части удалений, состояние надо проверить вручную. Ответы не пересказывают native body с потенциальными ключами. Между ownership GET и DELETE нет распределённой блокировки; конкурентные изменения и потеря процесса требуют отдельной приёмки.
Route отдельно обрабатывает audit revision failure после успешного apply:502,state applied и сообщение, что конфигурация уже применена, но ревизия не сохранена; сеть не объявляется неприменённой и автоматический rollback уже подтверждённых изменений не запускается. Этот backend audit branch реализован, но отказ реальной БД после apply в71 не воспроизведён. Success HTTP shape прежний; error state добавлен как уточнение, текущий UI показывает message и сохраняет форму.
Проверки apply unit16/16: native receipt успех, baseline read failure0writes, несовпавшая native identity, повтор после успеха409 без mutations, отказ второго peer/адреса с восстановлением baseline, успешный повтор после rollback, lost reply после iface/IP/peer commit,HTTP408/500,missing/foreign receipt,concurrent чужой peer,изменённая identity,отказ/неподтверждённый DELETE. Исходные preflight22/22 и codec/HTTP4 invalid cases passed; новые apply tests включены в backend test:wireguard. Browser6/6: прежние import сценарии и три новых502 состояния rolled-back/needs-review/applied сохраняют текст/server/error; автоматического повторного POST нет; ручной retry после rolled-back принят успешным receipt. HTTP ответы browser подменены, это не real RouterOS cleanup. Backend и scoped frontend TypeScript, ESLint и diff check passed. UI primitives/ReUI composition не менялись.
Исправление локальное: runtime backend/frontend479f2ad из70 не обновлялся; production frontend source не менялся. Следующий12.5.72 — production build/deploy только backend, реальная проверка ROS PUT receipts, поздний native rejection на собственных объектах,cleanup/baseline и исправленный повтор с связностью. Прерванный HTTP/процесс,подлинный audit DB failure,конкуренция/multi-instance,IPv6/throughput/RBAC,PSK/multi-address/unknown-conf поля,интеграции и остальные внешние протоколы остаются открытыми. Пароли/RouterOS конфигурация в этом локальном блоке не менялись. Локальный коммит без push.
+1 -1
View File
@@ -1,6 +1,6 @@
# План реализации UX/UI по пятичасовым окнам # План реализации UX/UI по пятичасовым окнам
Актуальное продолжение 2026-10-09: 12.5.70 развернул backend/frontend479f2ad и принял реальные отказы400/409 без native изменений, .conf roundtrip с явным Name на собственной WG паре обоих CHR и browser сохранение формы. Handshake/Ping3/3 в обе стороны, cleanup/baseline, DB controls и smoke6 страниц passed. Все три сервера доступны с прежними доступами. Следующий12.5.71 — ошибки частичного применения и повторный импорт WireGuard: сначала профильные проверки компенсации/receipt, затем собственный real fixture. Этап12.5 открыт. Актуальное продолжение 2026-10-09: 12.5.71 локально вынес WireGuard apply в сервис с receipt ownership, native подтверждением и компенсацией подтверждённых отказов. Потерянный ответ/unknown receipt/чужие объекты/неподтверждённая очистка →needs-review без удаления по имени; успешный повтор после rollback и409 повтор после успеха проверены. Apply unit16/16, preflight22/22, HTTP invalid cases, browser6/6, TypeScript/ESLint passed. Runtime остаётся backend/frontend479f2ad. Следующий12.5.72 — backend Docker build/deploy, реальный receipt/partial rejection/cleanup/retry на собственной WG паре; frontend479f2ad сохранить. Этап12.5 открыт.
Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-08. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат. Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-08. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат.
+16 -2
View File
@@ -1,6 +1,6 @@
# UX/UI: журнал реализации # UX/UI: журнал реализации
Актуальное продолжение 2026-10-09: 12.5.70 развернул backend/frontend479f2ad и принял реальные отказы400/409 без native изменений, .conf roundtrip с явным Name на собственной WG паре обоих CHR и browser сохранение формы. Handshake/Ping3/3 в обе стороны, cleanup/baseline, DB controls и smoke6 страниц passed. Все три сервера доступны с прежними доступами. Следующий12.5.71 — ошибки частичного применения и повторный импорт WireGuard: сначала профильные проверки компенсации/receipt, затем собственный real fixture. Этап12.5 открыт. Актуальное продолжение 2026-10-09: 12.5.71 локально вынес WireGuard apply в сервис с receipt ownership, native подтверждением и компенсацией подтверждённых отказов. Потерянный ответ/unknown receipt/чужие объекты/неподтверждённая очистка →needs-review без удаления по имени; успешный повтор после rollback и409 повтор после успеха проверены. Apply unit16/16, preflight22/22, HTTP invalid cases, browser6/6, TypeScript/ESLint passed. Runtime остаётся backend/frontend479f2ad. Следующий12.5.72 — backend Docker build/deploy, реальный receipt/partial rejection/cleanup/retry на собственной WG паре; frontend479f2ad сохранить. Этап12.5 открыт.
Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. Записи расположены от новых к старым; подробная история этапа12 также сохранена в [ux-ui-stage-12.md](ux-ui-stage-12.md). Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. Записи расположены от новых к старым; подробная история этапа12 также сохранена в [ux-ui-stage-12.md](ux-ui-stage-12.md).
@@ -34,7 +34,21 @@
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 | | 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Локальная приёмка завершена в границах12.3.83; внешняя12.5 в работе, выполненные блоки1–70 и ограничения ниже. Runtime backend/frontend479f2ad. | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Локальная приёмка завершена в границах12.3.83; внешняя12.5 в работе, выполненные блоки1–71 и ограничения ниже. Runtime backend/frontend479f2ad. |
## 12.5.71 — подтверждённая очистка частичного импорта WireGuard (2026-10-09)
Локально обнаружено по code: прежний последовательный apply оставлял интерфейс/IP, если поздний peer PUT отклонялся. Добавлен backend/src/services/wireguard-import-apply.ts; route использует сервис вместо встроенного applyParsedConfig. До mutations читаются interface/IP/peer baseline; занятое имя возвращает409. Каждый успешный create должен дать один строковый ROS .id, отсутствовавший в baseline; identity записывается в памяти. Native GET после всех PUT подтверждает созданные ID и name/interface/public-key/address association перед success receipt. Полное сравнение всех настроек/связности остаётся внешней проверкой, не подменяется этим identity check.
При подтверждённом RouterOS rejection (явные400/401/403/404/405/409/422) сервис читает текущее состояние, проверяет принадлежность всех receipt-owned объектов и отсутствие чужих связанных объектов. Только затем удаляет собственные peers →IP →interface в обратном порядке и перечитывает ресурсы. Подтверждённое отсутствие объектов →502,state rolled-back с понятным сообщением и возможностью исправить/повторить. Удаление существующих baseline IDs, чужих child rows и cleanup только по имени запрещены. Это компенсация текущего запроса, не RouterOS транзакция.
Потеря ответа после commit,HTTP408/500,неполный create receipt или несовпадение native identity →needs-review; автоматическое удаление не выполняется при неподтверждённом create outcome. Ошибка cleanup/read/изменённая identity/чужой concurrent child →needs-review, успешный rollback не обещается. Если DELETE оборвался после части удалений, состояние надо проверить вручную. Ответы не пересказывают native body с потенциальными ключами. Между ownership GET и DELETE нет распределённой блокировки; конкурентные изменения и потеря процесса требуют отдельной приёмки.
Route отдельно обрабатывает audit revision failure после успешного apply:502,state applied и сообщение, что конфигурация уже применена, но ревизия не сохранена; сеть не объявляется неприменённой и автоматический rollback уже подтверждённых изменений не запускается. Этот backend audit branch реализован, но отказ реальной БД после apply в71 не воспроизведён. Success HTTP shape прежний; error state добавлен как уточнение, текущий UI показывает message и сохраняет форму.
Проверки apply unit16/16: native receipt успех, baseline read failure0writes, несовпавшая native identity, повтор после успеха409 без mutations, отказ второго peer/адреса с восстановлением baseline, успешный повтор после rollback, lost reply после iface/IP/peer commit,HTTP408/500,missing/foreign receipt,concurrent чужой peer,изменённая identity,отказ/неподтверждённый DELETE. Исходные preflight22/22 и codec/HTTP4 invalid cases passed; новые apply tests включены в backend test:wireguard. Browser6/6: прежние import сценарии и три новых502 состояния rolled-back/needs-review/applied сохраняют текст/server/error; автоматического повторного POST нет; ручной retry после rolled-back принят успешным receipt. HTTP ответы browser подменены, это не real RouterOS cleanup. Backend и scoped frontend TypeScript, ESLint и diff check passed. UI primitives/ReUI composition не менялись.
Исправление локальное: runtime backend/frontend479f2ad из70 не обновлялся; production frontend source не менялся. Следующий12.5.72 — production build/deploy только backend, реальная проверка ROS PUT receipts, поздний native rejection на собственных объектах,cleanup/baseline и исправленный повтор с связностью. Прерванный HTTP/процесс,подлинный audit DB failure,конкуренция/multi-instance,IPv6/throughput/RBAC,PSK/multi-address/unknown-conf поля,интеграции и остальные внешние протоколы остаются открытыми. Пароли/RouterOS конфигурация в этом локальном блоке не менялись. Локальный коммит без push.
## 12.5.70 — deploy preflight и собственный .conf roundtrip WireGuard (2026-10-09) ## 12.5.70 — deploy preflight и собственный .conf roundtrip WireGuard (2026-10-09)
+15 -1
View File
@@ -1,6 +1,6 @@
# Этап 12: итоговая приёмка UX/UI # Этап 12: итоговая приёмка UX/UI
Актуальное продолжение 2026-10-09: 12.5.70 развернул backend/frontend479f2ad и принял реальные отказы400/409 без native изменений, .conf roundtrip с явным Name на собственной WG паре обоих CHR и browser сохранение формы. Handshake/Ping3/3 в обе стороны, cleanup/baseline, DB controls и smoke6 страниц passed. Все три сервера доступны с прежними доступами. Следующий12.5.71 — ошибки частичного применения и повторный импорт WireGuard: сначала профильные проверки компенсации/receipt, затем собственный real fixture. Этап12.5 открыт. Актуальное продолжение 2026-10-09: 12.5.71 локально вынес WireGuard apply в сервис с receipt ownership, native подтверждением и компенсацией подтверждённых отказов. Потерянный ответ/unknown receipt/чужие объекты/неподтверждённая очистка →needs-review без удаления по имени; успешный повтор после rollback и409 повтор после успеха проверены. Apply unit16/16, preflight22/22, HTTP invalid cases, browser6/6, TypeScript/ESLint passed. Runtime остаётся backend/frontend479f2ad. Следующий12.5.72 — backend Docker build/deploy, реальный receipt/partial rejection/cleanup/retry на собственной WG паре; frontend479f2ad сохранить. Этап12.5 открыт.
План: [ux-ui-plan.md](ux-ui-plan.md). Фактические результаты: [ux-ui-progress.md](ux-ui-progress.md). Подготовлен после сводных проверок 11Б; завершение каждого блока требует локального коммита без push. План: [ux-ui-plan.md](ux-ui-plan.md). Фактические результаты: [ux-ui-progress.md](ux-ui-progress.md). Подготовлен после сводных проверок 11Б; завершение каждого блока требует локального коммита без push.
@@ -1748,3 +1748,17 @@ Native checks подтверждают name,ключевую идентично
Все собственные объекты удалены; выбранные WG/peers/IP/routes/IPv4 filter/nat/mangle совпали с baseline на обоих CHR. Для imported conf без comment ownership подтверждалась уникальным fixture name и ожидаемой native public identity, используемой только в памяти. Остались3 основные службы, backend healthy. DB controls до/после:servers2,speedHistory9,retentionDays14,migrations9. Audit revisions намеренно сохранены. Production smoke /dashboard,/servers,/uptime?tab=speed,/settings,/filters,/wireguard →HTTP200,320px fits,0 page/API errors. Публичные JSON проверены на отсутствие ключей/конфигов/паролей. Дополнительная общая локальная регрессия не повторялась после уже проверенного69; production build/typecheck и реальные scoped сценарии выполнены. Все собственные объекты удалены; выбранные WG/peers/IP/routes/IPv4 filter/nat/mangle совпали с baseline на обоих CHR. Для imported conf без comment ownership подтверждалась уникальным fixture name и ожидаемой native public identity, используемой только в памяти. Остались3 основные службы, backend healthy. DB controls до/после:servers2,speedHistory9,retentionDays14,migrations9. Audit revisions намеренно сохранены. Production smoke /dashboard,/servers,/uptime?tab=speed,/settings,/filters,/wireguard →HTTP200,320px fits,0 page/API errors. Публичные JSON проверены на отсутствие ключей/конфигов/паролей. Дополнительная общая локальная регрессия не повторялась после уже проверенного69; production build/typecheck и реальные scoped сценарии выполнены.
Следующий12.5.71 — partial apply failures/компенсация и повторный import. Concurrent collision races, rollback/interrupted mutation, неизвестные/повторные поляconf,PSK/multi-address поддержка, ready Peer client template import,private-key UI download,IPv6/throughput/RBAC/multi-instance/Telegram/Portal и остальные внешние протоколы/интеграции остаются открытыми. Локальный коммит без push. Следующий12.5.71 — partial apply failures/компенсация и повторный import. Concurrent collision races, rollback/interrupted mutation, неизвестные/повторные поляconf,PSK/multi-address поддержка, ready Peer client template import,private-key UI download,IPv6/throughput/RBAC/multi-instance/Telegram/Portal и остальные внешние протоколы/интеграции остаются открытыми. Локальный коммит без push.
## 12.5.71 — подтверждённая очистка частичного импорта WireGuard (2026-10-09)
Локально обнаружено по code: прежний последовательный apply оставлял интерфейс/IP, если поздний peer PUT отклонялся. Добавлен backend/src/services/wireguard-import-apply.ts; route использует сервис вместо встроенного applyParsedConfig. До mutations читаются interface/IP/peer baseline; занятое имя возвращает409. Каждый успешный create должен дать один строковый ROS .id, отсутствовавший в baseline; identity записывается в памяти. Native GET после всех PUT подтверждает созданные ID и name/interface/public-key/address association перед success receipt. Полное сравнение всех настроек/связности остаётся внешней проверкой, не подменяется этим identity check.
При подтверждённом RouterOS rejection (явные400/401/403/404/405/409/422) сервис читает текущее состояние, проверяет принадлежность всех receipt-owned объектов и отсутствие чужих связанных объектов. Только затем удаляет собственные peers →IP →interface в обратном порядке и перечитывает ресурсы. Подтверждённое отсутствие объектов →502,state rolled-back с понятным сообщением и возможностью исправить/повторить. Удаление существующих baseline IDs, чужих child rows и cleanup только по имени запрещены. Это компенсация текущего запроса, не RouterOS транзакция.
Потеря ответа после commit,HTTP408/500,неполный create receipt или несовпадение native identity →needs-review; автоматическое удаление не выполняется при неподтверждённом create outcome. Ошибка cleanup/read/изменённая identity/чужой concurrent child →needs-review, успешный rollback не обещается. Если DELETE оборвался после части удалений, состояние надо проверить вручную. Ответы не пересказывают native body с потенциальными ключами. Между ownership GET и DELETE нет распределённой блокировки; конкурентные изменения и потеря процесса требуют отдельной приёмки.
Route отдельно обрабатывает audit revision failure после успешного apply:502,state applied и сообщение, что конфигурация уже применена, но ревизия не сохранена; сеть не объявляется неприменённой и автоматический rollback уже подтверждённых изменений не запускается. Этот backend audit branch реализован, но отказ реальной БД после apply в71 не воспроизведён. Success HTTP shape прежний; error state добавлен как уточнение, текущий UI показывает message и сохраняет форму.
Проверки apply unit16/16: native receipt успех, baseline read failure0writes, несовпавшая native identity, повтор после успеха409 без mutations, отказ второго peer/адреса с восстановлением baseline, успешный повтор после rollback, lost reply после iface/IP/peer commit,HTTP408/500,missing/foreign receipt,concurrent чужой peer,изменённая identity,отказ/неподтверждённый DELETE. Исходные preflight22/22 и codec/HTTP4 invalid cases passed; новые apply tests включены в backend test:wireguard. Browser6/6: прежние import сценарии и три новых502 состояния rolled-back/needs-review/applied сохраняют текст/server/error; автоматического повторного POST нет; ручной retry после rolled-back принят успешным receipt. HTTP ответы browser подменены, это не real RouterOS cleanup. Backend и scoped frontend TypeScript, ESLint и diff check passed. UI primitives/ReUI composition не менялись.
Исправление локальное: runtime backend/frontend479f2ad из70 не обновлялся; production frontend source не менялся. Следующий12.5.72 — production build/deploy только backend, реальная проверка ROS PUT receipts, поздний native rejection на собственных объектах,cleanup/baseline и исправленный повтор с связностью. Прерванный HTTP/процесс,подлинный audit DB failure,конкуренция/multi-instance,IPv6/throughput/RBAC,PSK/multi-address/unknown-conf поля,интеграции и остальные внешние протоколы остаются открытыми. Пароли/RouterOS конфигурация в этом локальном блоке не менялись. Локальный коммит без push.
+14
View File
@@ -591,3 +591,17 @@ Native checks подтверждают name,ключевую идентично
Все собственные объекты удалены; выбранные WG/peers/IP/routes/IPv4 filter/nat/mangle совпали с baseline на обоих CHR. Для imported conf без comment ownership подтверждалась уникальным fixture name и ожидаемой native public identity, используемой только в памяти. Остались3 основные службы, backend healthy. DB controls до/после:servers2,speedHistory9,retentionDays14,migrations9. Audit revisions намеренно сохранены. Production smoke /dashboard,/servers,/uptime?tab=speed,/settings,/filters,/wireguard →HTTP200,320px fits,0 page/API errors. Публичные JSON проверены на отсутствие ключей/конфигов/паролей. Дополнительная общая локальная регрессия не повторялась после уже проверенного69; production build/typecheck и реальные scoped сценарии выполнены. Все собственные объекты удалены; выбранные WG/peers/IP/routes/IPv4 filter/nat/mangle совпали с baseline на обоих CHR. Для imported conf без comment ownership подтверждалась уникальным fixture name и ожидаемой native public identity, используемой только в памяти. Остались3 основные службы, backend healthy. DB controls до/после:servers2,speedHistory9,retentionDays14,migrations9. Audit revisions намеренно сохранены. Production smoke /dashboard,/servers,/uptime?tab=speed,/settings,/filters,/wireguard →HTTP200,320px fits,0 page/API errors. Публичные JSON проверены на отсутствие ключей/конфигов/паролей. Дополнительная общая локальная регрессия не повторялась после уже проверенного69; production build/typecheck и реальные scoped сценарии выполнены.
Следующий12.5.71 — partial apply failures/компенсация и повторный import. Concurrent collision races, rollback/interrupted mutation, неизвестные/повторные поляconf,PSK/multi-address поддержка, ready Peer client template import,private-key UI download,IPv6/throughput/RBAC/multi-instance/Telegram/Portal и остальные внешние протоколы/интеграции остаются открытыми. Локальный коммит без push. Следующий12.5.71 — partial apply failures/компенсация и повторный import. Concurrent collision races, rollback/interrupted mutation, неизвестные/повторные поляconf,PSK/multi-address поддержка, ready Peer client template import,private-key UI download,IPv6/throughput/RBAC/multi-instance/Telegram/Portal и остальные внешние протоколы/интеграции остаются открытыми. Локальный коммит без push.
## 12.5.71 — подтверждённая очистка частичного импорта WireGuard (2026-10-09)
Локально обнаружено по code: прежний последовательный apply оставлял интерфейс/IP, если поздний peer PUT отклонялся. Добавлен backend/src/services/wireguard-import-apply.ts; route использует сервис вместо встроенного applyParsedConfig. До mutations читаются interface/IP/peer baseline; занятое имя возвращает409. Каждый успешный create должен дать один строковый ROS .id, отсутствовавший в baseline; identity записывается в памяти. Native GET после всех PUT подтверждает созданные ID и name/interface/public-key/address association перед success receipt. Полное сравнение всех настроек/связности остаётся внешней проверкой, не подменяется этим identity check.
При подтверждённом RouterOS rejection (явные400/401/403/404/405/409/422) сервис читает текущее состояние, проверяет принадлежность всех receipt-owned объектов и отсутствие чужих связанных объектов. Только затем удаляет собственные peers →IP →interface в обратном порядке и перечитывает ресурсы. Подтверждённое отсутствие объектов →502,state rolled-back с понятным сообщением и возможностью исправить/повторить. Удаление существующих baseline IDs, чужих child rows и cleanup только по имени запрещены. Это компенсация текущего запроса, не RouterOS транзакция.
Потеря ответа после commit,HTTP408/500,неполный create receipt или несовпадение native identity →needs-review; автоматическое удаление не выполняется при неподтверждённом create outcome. Ошибка cleanup/read/изменённая identity/чужой concurrent child →needs-review, успешный rollback не обещается. Если DELETE оборвался после части удалений, состояние надо проверить вручную. Ответы не пересказывают native body с потенциальными ключами. Между ownership GET и DELETE нет распределённой блокировки; конкурентные изменения и потеря процесса требуют отдельной приёмки.
Route отдельно обрабатывает audit revision failure после успешного apply:502,state applied и сообщение, что конфигурация уже применена, но ревизия не сохранена; сеть не объявляется неприменённой и автоматический rollback уже подтверждённых изменений не запускается. Этот backend audit branch реализован, но отказ реальной БД после apply в71 не воспроизведён. Success HTTP shape прежний; error state добавлен как уточнение, текущий UI показывает message и сохраняет форму.
Проверки apply unit16/16: native receipt успех, baseline read failure0writes, несовпавшая native identity, повтор после успеха409 без mutations, отказ второго peer/адреса с восстановлением baseline, успешный повтор после rollback, lost reply после iface/IP/peer commit,HTTP408/500,missing/foreign receipt,concurrent чужой peer,изменённая identity,отказ/неподтверждённый DELETE. Исходные preflight22/22 и codec/HTTP4 invalid cases passed; новые apply tests включены в backend test:wireguard. Browser6/6: прежние import сценарии и три новых502 состояния rolled-back/needs-review/applied сохраняют текст/server/error; автоматического повторного POST нет; ручной retry после rolled-back принят успешным receipt. HTTP ответы browser подменены, это не real RouterOS cleanup. Backend и scoped frontend TypeScript, ESLint и diff check passed. UI primitives/ReUI composition не менялись.
Исправление локальное: runtime backend/frontend479f2ad из70 не обновлялся; production frontend source не менялся. Следующий12.5.72 — production build/deploy только backend, реальная проверка ROS PUT receipts, поздний native rejection на собственных объектах,cleanup/baseline и исправленный повтор с связностью. Прерванный HTTP/процесс,подлинный audit DB failure,конкуренция/multi-instance,IPv6/throughput/RBAC,PSK/multi-address/unknown-conf поля,интеграции и остальные внешние протоколы остаются открытыми. Пароли/RouterOS конфигурация в этом локальном блоке не менялись. Локальный коммит без push.
+30
View File
@@ -229,6 +229,36 @@ test("WireGuard: ошибка проверки .conf сохраняет клие
expect(attempts).toBe(1) expect(attempts).toBe(1)
}) })
for (const [state, message] of [
["rolled-back", "RouterOS отклонил импорт. Созданные этим запросом объекты удалены; исправьте конфигурацию и повторите"],
["needs-review", "Не удалось подтвердить результат импорта или очистки. Проверьте интерфейс на роутере перед повтором"],
["applied", "Конфигурация применена, но ревизия не сохранена. Обновите данные перед повтором"],
] as const) {
test(`WireGuard: импорт ${state} сохраняет форму и не повторяет запрос автоматически`, async ({ page }) => {
await setup(page, "/wireguard?server=1")
const content = "/interface wireguard add name=wg-retry"
let attempts = 0
await page.route("**/api/wireguard/import", route => {
attempts++
if (attempts === 1) return route.fulfill({ status: 502, json: { error: message, state } })
return route.fulfill({ json: { dryRun: false, preview: { format: "rsc", interface: { name: "wg-retry" }, peers: [] }, applied: { interfaceName: "wg-retry", peersCreated: 0 } } })
})
await page.getByRole("button", { name: "Импорт", exact: true }).click()
const dialog = page.getByRole("dialog", { name: "Импорт конфига WireGuard" })
await dialog.getByLabel("Конфигурация WireGuard", { exact: true }).fill(content)
await dialog.getByRole("button", { name: "Применить на роутер" }).click()
await expect(dialog.getByRole("alert")).toContainText(message)
await expect(dialog.getByLabel("Конфигурация WireGuard", { exact: true })).toHaveValue(content)
await expect(dialog.getByLabel("Сервер", { exact: true })).toHaveValue("1")
expect(attempts).toBe(1)
if (state === "rolled-back") {
await dialog.getByRole("button", { name: "Применить на роутер" }).click()
await expect(dialog).toHaveCount(0)
expect(attempts).toBe(2)
}
})
}
test("WireGuard: импорт удерживает форму до ответа и разрешает повтор", async ({ page }) => { test("WireGuard: импорт удерживает форму до ответа и разрешает повтор", async ({ page }) => {
await setup(page, "/wireguard?server=1") await setup(page, "/wireguard?server=1")
let attempts = 0 let attempts = 0