fix(api): проверять параметры запуска теста скорости до операции

This commit is contained in:
Denozordec committed 2026-10-07 17:30:52 +07:00
1 parent b0e114b56d
commit b59944b573
7 files changed
+114 -62

No files matched your search

+44
View File
@@ -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<SpeedTestCommand & { message: string }> = []
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")
+32
View File
@@ -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<boolean>
run: (command: SpeedTestCommand) => Promise<SpeedTestRunResult>
failure: (command: SpeedTestCommand & { message: string }) => Promise<void>
}): 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 })
}
})
}
}
+6 -59
View File
@@ -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 }
+1 -1
View File
@@ -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). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат.
+10 -2
View File
@@ -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)
+9
View File
@@ -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 полным параметрам без изменения сетевых команд.
+12
View File
@@ -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<typeof speedTestCommandSchema>