fix(api): проверять параметры запуска теста скорости до операции
This commit is contained in:
1 parent
b0e114b56d
commit
b59944b573
7 files changed
+114
-62
No files matched your search
@@ -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")
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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 полным параметрам без изменения сетевых команд.
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user