diff --git a/backend/src/routes/speed-test.test.ts b/backend/src/routes/speed-test.test.ts new file mode 100644 index 00000000..10ef96d6 --- /dev/null +++ b/backend/src/routes/speed-test.test.ts @@ -0,0 +1,44 @@ +import assert from "node:assert/strict" +import Fastify from "fastify" +import { SignJWT } from "jose" +import { speedTestCommandSchema, type SpeedTestCommand } from "@mmapp/contracts" +import { createSpeedTestRoute } from "./speed-test.js" +import { SpeedProbeEndpointsChanged } from "../services/speed-run-persistence.js" +const commands: SpeedTestCommand[] = [], failures: Array = [] +let lookups = 0, missing = false, problem: Error | null = null +const result = (c: SpeedTestCommand) => ({ runId: c.runId ?? "ad-hoc", srcInterface: c.srcInterface || null, dstInterface: c.dstInterface || null, address: "192.0.2.2", srcAddress: "192.0.2.1", dstAddress: "192.0.2.2", srcInterfaceAddress: null, dstInterfaceAddress: null, protocol: c.protocol, direction: c.direction, durationSec: c.durationSec, txAvgMbps: null, rxAvgMbps: 0, txSeries: [], rxSeries: [0], raw: [{ status: "done testing" }], afterBtPing: { rttMs: null, lossPct: null, error: null } }) +const deps = { + serverExists: async (_id: number) => { lookups++; return !missing }, + run: async (c: SpeedTestCommand) => { commands.push(c); if (problem) throw problem; return result(c) }, + failure: async (c: SpeedTestCommand & { message: string }) => { failures.push(c) }, +} +const app = Fastify(); await app.register(createSpeedTestRoute(deps), { prefix: "/api" }) +const send = (body: unknown) => app.inject({ method: "POST", url: "/api/uptime/speed-test", headers: { "content-type": "application/json" }, payload: JSON.stringify(body) }) +const base = { srcServerId: 1, dstServerId: 2 } +const bad = [null, [], {}, { ...base, srcServerId: "1junk" }, { ...base, srcServerId: "1.0" }, { ...base, srcServerId: "01" }, { ...base, srcServerId: true }, { ...base, srcServerId: 0 }, { ...base, srcServerId: -1 }, { ...base, srcServerId: 1.5 }, { ...base, srcServerId: 9007199254740992 }, { ...base, dstServerId: "1" }, { ...base, protocol: "TCP" }, { ...base, protocol: null }, { ...base, direction: "other" }, { ...base, durationSec: "10junk" }, { ...base, durationSec: "3.5" }, { ...base, durationSec: 2 }, { ...base, durationSec: 2147483648 }, { ...base, srcInterface: null }, { ...base, runId: 123 }, { ...base, probeId: false }, ...["runId", "probeId", "srcInterface", "dstInterface"].map(key => ({ ...base, [key]: "bad" + String.fromCharCode(0) }))] +for (const body of bad) assert.equal((await send(body)).statusCode, 400, JSON.stringify(body)) +assert.equal(lookups, 0); assert.equal(commands.length, 0); assert.equal(failures.length, 0) +assert.equal(speedTestCommandSchema.safeParse({ ...base, durationSec: Infinity }).success, false) +const first = await send(base); assert.equal(first.statusCode, 200); assert.equal(first.json().result.txAvgMbps, null); assert.equal(first.json().result.rxAvgMbps, 0) +assert.deepEqual(commands[0], { ...base, srcInterface: "", dstInterface: "", protocol: "tcp", direction: "both", durationSec: 10 }) +const explicit = await send({ srcServerId: "1", dstServerId: "2", runId: " run-2 ", probeId: " probe ", srcInterface: " ether1 ", protocol: "udp", direction: "receive", durationSec: "3", lastTxAvgMbps: 999 }) +assert.equal(explicit.statusCode, 200); assert.equal(commands[1].runId, "run-2"); assert.equal(commands[1].probeId, "probe"); assert.equal(commands[1].srcInterface, "ether1"); assert.equal(commands[1].protocol, "udp"); assert.equal(commands[1].direction, "receive"); assert.equal(commands[1].durationSec, 3); assert.equal("lastTxAvgMbps" in commands[1], false) +assert.equal(speedTestCommandSchema.parse({ ...base, runId: " ", probeId: "" }).probeId, undefined) +missing = true; assert.equal((await send(base)).statusCode, 404); assert.equal(commands.length, 2); assert.equal(failures.length, 0); missing = false +for (const [error, code] of [[new SpeedProbeEndpointsChanged(), 409], [Error("На интерфейсе назначения нет активного IP"), 400], [Error("Server not found"), 400], [Error("authentication failed"), 502], [Error("socket hang up"), 502]] as const) { + problem = error; const response = await send({ ...base, runId: "failed", direction: "transmit", durationSec: 20 }); assert.equal(response.statusCode, code); assert.equal(failures.at(-1)?.runId, "failed"); assert.equal(failures.at(-1)?.durationSec, 20); assert.equal(failures.at(-1)?.direction, "transmit") +} +await app.close(); console.log("Speed-test HTTP:26 invalid inputs before lookups/native/history; defaults/ad-hoc/trim/nullable/zero/404/409/400/502") +process.env.AUTH_REQUIRED = "true"; process.env.AUTH_JWT_SECRET = "speed-command-test-secret"; process.env.AUTH_ISSUER = "https://auth.test.local"; process.env.NODE_ENV = "test" +const { default: authPlugin, requireAuth } = await import("../plugins/auth.js") +const secured = Fastify(); await secured.register(authPlugin); secured.addHook("preHandler", requireAuth) +problem = null; const before = { lookups, runs: commands.length } +await secured.register(createSpeedTestRoute(deps), { prefix: "/api" }) +const authorized = (token?: string) => secured.inject({ method: "POST", url: "/api/uptime/speed-test", payload: base, headers: token ? { authorization: "Bearer " + token } : {} }) +assert.equal((await authorized()).statusCode, 401) +for (const permission of ["mm:uptime:read", "mm:servers:write", "mm:uptime:write"]) { + const token = await new SignJWT({ sub: "fixture", apps: ["mm"], permissions: [permission] }).setProtectedHeader({ alg: "HS256" }).setIssuer("https://auth.test.local").setExpirationTime("1h").sign(new TextEncoder().encode("speed-command-test-secret")) + assert.equal((await authorized(token)).statusCode, permission === "mm:uptime:write" ? 200 : 403) +} +assert.equal(lookups, before.lookups + 2); assert.equal(commands.length, before.runs + 1) +await secured.close(); console.log("Speed-test RBAC:401/403 before dependencies, uptime writer accepted; no PostgreSQL or RouterOS") diff --git a/backend/src/routes/speed-test.ts b/backend/src/routes/speed-test.ts new file mode 100644 index 00000000..6079335d --- /dev/null +++ b/backend/src/routes/speed-test.ts @@ -0,0 +1,32 @@ +import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" +import { speedTestCommandSchema, type SpeedTestCommand } from "@mmapp/contracts" +import type { SpeedTestRunResult } from "../services/uptime-speed-service.js" +import { SpeedProbeEndpointsChanged } from "../services/speed-run-persistence.js" + +export function createSpeedTestRoute(deps: { + serverExists: (id: number) => Promise + run: (command: SpeedTestCommand) => Promise + failure: (command: SpeedTestCommand & { message: string }) => Promise +}): FastifyPluginAsyncZod { + return async app => { + app.post("/uptime/speed-test", async (req, reply) => { + const parsed = speedTestCommandSchema.safeParse(req.body) + if (!parsed.success) return reply.code(400).send({ error: "Проверьте источник, назначение, протокол, направление и целую длительность теста от 3 секунд." }) + const command = parsed.data + const exists = await Promise.all([deps.serverExists(command.srcServerId), deps.serverExists(command.dstServerId)]) + if (exists.some(value => !value)) return reply.code(404).send({ error: "Server not found" }) + try { + return reply.send({ ok: true, result: await deps.run(command) }) + } catch (error) { + const raw = error instanceof Error ? error.message : "Bandwidth test failed" + await deps.failure({ ...command, message: raw }) + const message = /timed out|socket hang up|ECONNRESET/i.test(raw) + ? "BTTest timeout/socket hang up: проверь доступность destination, /tool bandwidth-server, firewall и корректность интерфейсов" + : raw + if (error instanceof SpeedProbeEndpointsChanged) return reply.code(409).send({ error: message }) + if (/активного IP|Server not found/i.test(raw)) return reply.code(400).send({ error: message }) + return reply.code(502).send({ error: message }) + } + }) + } +} diff --git a/backend/src/routes/uptime.ts b/backend/src/routes/uptime.ts index ae5e89a8..3ec7aa4c 100644 --- a/backend/src/routes/uptime.ts +++ b/backend/src/routes/uptime.ts @@ -1,5 +1,5 @@ import { emptySpeedProbeObservations } from "@mmapp/contracts" -import { SpeedProbeEndpointsChanged } from "../services/speed-run-persistence.js" +import { createSpeedTestRoute } from "./speed-test.js" import { createPingProbesWriteRoute } from "./ping-probes-write.js" import { createSpeedProbesWriteRoute } from "./speed-probes-write.js" import { replaceSpeedProbes } from "../services/speed-probe-replacement.js" @@ -259,64 +259,11 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => { } }) - app.post("/uptime/speed-test", async (req, reply) => { - const body = req.body as { - runId?: string - probeId?: string - srcServerId?: string | number - dstServerId?: string | number - srcInterface?: string - dstInterface?: string - protocol?: "tcp" | "udp" - direction?: "transmit" | "receive" | "both" - durationSec?: string | number - } - const srcId = Number.parseInt(String(body.srcServerId ?? ""), 10) - const dstId = Number.parseInt(String(body.dstServerId ?? ""), 10) - if (!Number.isFinite(srcId) || !Number.isFinite(dstId)) { - return reply.status(400).send({ error: "Invalid src/dst server id" }) - } - if (srcId === dstId) return reply.status(400).send({ error: "Source and destination must be different" }) - const src = (await db.select().from(servers).where(eq(servers.id, srcId)).limit(1))[0] - const dst = (await db.select().from(servers).where(eq(servers.id, dstId)).limit(1))[0] - if (!src || !dst) return reply.status(404).send({ error: "Server not found" }) - try { - const result = await runBandwidthSpeedTest({ - runId: String(body.runId ?? "").trim() || undefined, - probeId: String(body.probeId ?? "").trim() || undefined, - srcServerId: srcId, - dstServerId: dstId, - srcInterface: String(body.srcInterface ?? "").trim() || undefined, - dstInterface: String(body.dstInterface ?? "").trim() || undefined, - protocol: body.protocol === "udp" ? "udp" : "tcp", - direction: body.direction === "transmit" || body.direction === "receive" ? body.direction : "both", - durationSec: Math.max(3, Number.parseInt(String(body.durationSec ?? ""), 10) || 10), - }) - return reply.send({ ok: true, result }) - } catch (e) { - const raw = e instanceof Error ? e.message : "Bandwidth test failed" - await recordSpeedTestFailure({ - probeId: String(body.probeId ?? "").trim() || undefined, - runId: String(body.runId ?? "").trim() || undefined, - srcServerId: srcId, - dstServerId: dstId, - srcInterface: String(body.srcInterface ?? "").trim(), - dstInterface: String(body.dstInterface ?? "").trim(), - protocol: body.protocol === "udp" ? "udp" : "tcp", - direction: body.direction === "transmit" || body.direction === "receive" ? body.direction : "both", - durationSec: Math.max(3, Number.parseInt(String(body.durationSec ?? ""), 10) || 10), - message: raw, - }) - const message = /timed out|socket hang up|ECONNRESET/i.test(raw) - ? "BTTest timeout/socket hang up: проверь доступность destination, /tool bandwidth-server, firewall и корректность интерфейсов" - : raw - if (e instanceof SpeedProbeEndpointsChanged) return reply.status(409).send({ error: message }) - if (/активного IP|Server not found/i.test(raw)) { - return reply.status(400).send({ error: message }) - } - return reply.status(502).send({ error: message }) - } - }) + await app.register(createSpeedTestRoute({ + serverExists: async id => Boolean((await db.select({ id: servers.id }).from(servers).where(eq(servers.id, id)).limit(1))[0]), + run: runBandwidthSpeedTest, + failure: recordSpeedTestFailure, + })) app.get("/uptime/speed-test/endpoints/:id/interfaces", async (req, reply) => { const params = req.params as { id?: string | number } diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index c5deefc8..6194b0fc 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -1,6 +1,6 @@ # План реализации UX/UI по пятичасовым окнам -Актуальное продолжение 2026-10-07: 12.3.78/78.1 завершены: pair snapshot/history,73browser/8context,14pure+HTTP/RBAC,types/build; uptime lint0warnings. Общие main1176(579e816)/context221(a3fcba0) сохранены отдельно. Следующий12.3.79: строгий runtime body POST /uptime/speed-test до native операции (prefix IDs/enums/duration), команды/права сохранить. Внешняя12.5 не выполнена. +Актуальное продолжение 2026-10-07: 12.3.79 завершён: строгий POST speed-test, 26 отказов до DB/native, HTTP/RBAC и 16 pure checks; contracts/backend/frontend build прошли. Общие main1176(579e816)/context221(a3fcba0) относятся к указанным HEAD. Следующий12.3.80: принадлежность результата полным параметрам пробы. Внешняя12.5 не выполнена. Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-04. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 390db1de..510fff6b 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -1,6 +1,6 @@ # UX/UI: журнал реализации -Актуальное продолжение 2026-10-07: 12.3.78/78.1 завершены: pair snapshot/history,73browser/8context,14pure+HTTP/RBAC,types/build; uptime lint0warnings. Общие main1176(579e816)/context221(a3fcba0) сохранены отдельно. Следующий12.3.79: строгий runtime body POST /uptime/speed-test до native операции (prefix IDs/enums/duration), команды/права сохранить. Внешняя12.5 не выполнена. +Актуальное продолжение 2026-10-07: 12.3.79 завершён: строгий POST speed-test, 26 отказов до DB/native, HTTP/RBAC и 16 pure checks; contracts/backend/frontend build прошли. Общие main1176(579e816)/context221(a3fcba0) относятся к указанным HEAD. Следующий12.3.80: принадлежность результата полным параметрам пробы. Внешняя12.5 не выполнена. Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. Записи расположены от новых к старым; подробная история этапа12 также сохранена в [ux-ui-stage-12.md](ux-ui-stage-12.md). @@ -32,7 +32,15 @@ | 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 | -| 12 | Итоговая доступность, адаптивность, производительность и регрессия | В работе. Локальные блоки до12.3.78.1 завершены. Общая main1176/1176 на579e816 и context221/221 наa3fcba0 прошли; последующий функциональный delta проверен73browser/8context. Следующий12.3.79; внешняя12.5 не выполнена. | +| 12 | Итоговая доступность, адаптивность, производительность и регрессия | В работе. Локальные блоки до12.3.79 завершены. Общая main1176/1176 на579e816 и context221/221 наa3fcba0 прошли; последующий функциональный delta проверен73browser/8context. Следующий12.3.80; внешняя12.5 не выполнена. | + +## 12.3.79 — строгие параметры команды speed-test (2026-10-07) + +POST /uptime/speed-test проверяет runtime DTO до поиска устройств и запуска native операции. Отклоняются частичные/неканонические ID, неверные enum, дробная или недопустимая длительность, неверные типы текстовых полей и NUL. Валидные значения по умолчанию tcp/both/10s, ad-hoc без probeId, обрезка пробелов и прежние команды/права сохранены. Проверка вынесена в общий контракт, HTTP route — в фабрику с зависимостями. Ошибки запуска сохраняют нормализованные параметры; конфликт пары даёт409. + +26 неверных HTTP bodies не вызывают поиск серверов, запуск или запись истории. Fastify inject проверил defaults/ad-hoc,404,409,400,502/timeout и JWT401/403/разрешённый mm:uptime:write. Дополнительно16 pure rate/status/persistence проверок, contracts/backend/frontend production builds, non-incremental TypeScript, targeted contract lint и diff check прошли. PostgreSQL и RouterOS не запускались; внешний12.5 остаётся открытым. Общие main/context результаты не объявлены проверкой нового HEAD. + +Следующий12.3.80: текущая привязка защищает пару устройств, но не изменение интерфейсов, протокола, направления и длительности во время теста; проверить принадлежность snapshot полным параметрам без изменения сетевых команд. ## 12.3.78.1 — удаление неиспользуемого кода прежней таблицы (2026-10-07) diff --git a/docs/ux-ui-stage-12.md b/docs/ux-ui-stage-12.md index 1ab6f755..e66fccf4 100644 --- a/docs/ux-ui-stage-12.md +++ b/docs/ux-ui-stage-12.md @@ -894,3 +894,12 @@ Main1176 на579e816 и backend ownership delta25pure/5browser наa3fcba0 ос 2026-10-07. Из uptime удалены неиспользуемые MiniBar/resBarColor/resPctColor/SortIcon/ResSortKey,2импорта и чистое вычислениеy1,3неактуальных eslint-disable directives. Reference search и предыдущий lint подтверждали отсутствие использования; активные render/API/measurement paths не менялись. Установленные ReUI primitives/hooks/theme не изменены. Targeted lint страницы/helpers/tests теперь0errors/0warnings (до этого9warnings), non-incremental TypeScript и production frontend build passed. Для удаления неиспользуемых объявлений не добавлены повторяющие реализацию тесты; функциональный78 delta73browser/8context/14pure+backend HTTP/RBAC сохранён в предыдущем блоке. Следующий79: runtime command body schema до DB/native. Внешняя12.5 не выполнена; полная матрица относится к точным ранее указанным HEAD. + +## 12.3.79 — строгие параметры команды speed-test (2026-10-07) + +POST /uptime/speed-test проверяет runtime DTO до поиска устройств и запуска native операции. Отклоняются частичные/неканонические ID, неверные enum, дробная или недопустимая длительность, неверные типы текстовых полей и NUL. Валидные значения по умолчанию tcp/both/10s, ad-hoc без probeId, обрезка пробелов и прежние команды/права сохранены. Проверка вынесена в общий контракт, HTTP route — в фабрику с зависимостями. Ошибки запуска сохраняют нормализованные параметры; конфликт пары даёт409. + +26 неверных HTTP bodies не вызывают поиск серверов, запуск или запись истории. Fastify inject проверил defaults/ad-hoc,404,409,400,502/timeout и JWT401/403/разрешённый mm:uptime:write. Дополнительно16 pure rate/status/persistence проверок, contracts/backend/frontend production builds, non-incremental TypeScript, targeted contract lint и diff check прошли. PostgreSQL и RouterOS не запускались; внешний12.5 остаётся открытым. Общие main/context результаты не объявлены проверкой нового HEAD. + +Следующий12.3.80: текущая привязка защищает пару устройств, но не изменение интерфейсов, протокола, направления и длительности во время теста; проверить принадлежность snapshot полным параметрам без изменения сетевых команд. + diff --git a/packages/contracts/src/uptime-speed.ts b/packages/contracts/src/uptime-speed.ts index 7e99dc1d..9f423cfd 100644 --- a/packages/contracts/src/uptime-speed.ts +++ b/packages/contracts/src/uptime-speed.ts @@ -44,3 +44,15 @@ export const emptySpeedProbeObservations = { lastRunAt: null, lastTxAvgMbps: null, lastRxAvgMbps: null, lastStatus: null, lastError: null, lastPingRttMs: null, lastPingLossPct: null, lastPingAt: null, lastPingError: null, } + +const speedCommandText = z.string().refine(value => !value.includes(String.fromCharCode(0)), "Недопустимый символ в параметре теста") +const speedOptionalId = speedCommandText.trim().transform(value => value || undefined).optional() +export const speedTestCommandSchema = z.object({ + runId: speedOptionalId, probeId: speedOptionalId, + srcServerId: speedPositiveInteger, dstServerId: speedPositiveInteger, + srcInterface: speedCommandText.trim().default(""), dstInterface: speedCommandText.trim().default(""), + protocol: z.enum(["tcp", "udp"]).default("tcp"), + direction: z.enum(["transmit", "receive", "both"]).default("both"), + durationSec: speedPositiveInteger.pipe(z.number().min(3).max(2147483647)).default(10), +}).refine(row => row.srcServerId !== row.dstServerId, "Источник и назначение должны отличаться") +export type SpeedTestCommand = z.infer