Files
MikrotikManager/backend/src/test/mcp-flow-analytics.ts
T

76 lines
7.4 KiB
TypeScript

import assert from "node:assert/strict"
import { randomUUID } from "node:crypto"
import { mcpConnectionCreateSchema } from "@mmapp/contracts/mcp"
import type { FlowAnalyticsDto } from "@mmapp/contracts/traffic-flow"
import type { AuthUser } from "../lib/permissions.js"
import { pool } from "../db/index.js"
import { ensurePartitionFor } from "../db/partitions.js"
import { createMcpConnection, authenticateMcpToken, setConnectionStatus } from "../modules/mcp/store.js"
import { invokeMcpTool } from "../modules/mcp/runtime.js"
import { buildFlowAnalytics, invalidateFlowAnalyticsCache } from "../services/traffic-flow-analytics.js"
import { loadFlowTopology, invalidateFlowCatalogCache } from "../services/traffic-flow-topology.js"
import { resetFlowRingsForTests as clearPendingFlows } from "../services/traffic-flow-ingest.js"
import { disableRipeEnqueueForTests, resetRipeCacheForTests } from "../services/traffic-flow-ripe.js"
import { duringFlowSourceQuery } from "./mcp-flow-query-race.js"
export async function verifyMcpFlowAnalytics(actor: AuthUser) {
const owner = { ...actor, id: "flow-analytics-owner", permissions: [...actor.permissions, "mm:traffic:read"] }
const users = ["analytics-local", "analytics-mixed", "analytics-peer"]
await pool.query("INSERT INTO servers(id,name,host,password) VALUES(110,'analytics-visible','192.0.2.110','NEVER_ANALYTICS_PASSWORD'),(111,'NEVER_ANALYTICS_HIDDEN_SERVER','192.0.2.111','NEVER_ANALYTICS_PASSWORD')")
const access = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: "Scoped analytics", mode: "observer", expiresAt: new Date(Date.now()+1800000).toISOString(),
permissions: ["mm:traffic:read"], scope: { serverIds: [110], operationIds: ["traffic.flow.analytics.read"], allowGlobal: false }, limits: { callsPerMinute: 120 } }), owner)
const principal = await authenticateMcpToken(access.token)
const read = (extra: Record<string, unknown> = {}) => invokeMcpTool(principal, "mm_netflow_analytics_report_get", { serverId: 110, minutes: 5, ...extra }) as Promise<{ analytics: FlowAnalyticsDto; pages: Record<string, { total: number; nextOffset: number | null }>; live: boolean; forwardingVerified: boolean }>
const bucket = new Date(Date.now()-60000).toISOString()
const put = (sid: number, name: string, bytes: string | number, src: string, at = bucket) => pool.query("INSERT INTO flow_buckets(server_id,bucket_at,src,dst,proto,src_port,dst_port,bytes,packets,in_iface) VALUES($1,$2,$3,'8.8.8.8',6,12345,443,$4,1,$5)", [sid,at,src,bytes,name])
try {
for (const at of [new Date(bucket), new Date(Date.now()+3600000)]) await ensurePartitionFor(pool,"flow_buckets","day",at)
disableRipeEnqueueForTests()
for (const id of users) await pool.query("INSERT INTO app_users(id,login,name) VALUES($1,$1,$2)", [id,id === "analytics-mixed" ? "NEVER_ANALYTICS_MIXED_USER" : id])
for (const [userId,serverId,interfaceName,peer] of [[users[0],110,"analytics-in",""],[users[1],110,"analytics-mixed-in",""],[users[1],111,"NEVER_ANALYTICS_HIDDEN_IFACE",""],[users[2],110,"analytics-peer-in","NEVER_ANALYTICS_PEER_KEY"]] as const)
await pool.query("INSERT INTO user_interface_bindings(id,user_id,server_id,interface_name,peer_public_key) VALUES($1,$2,$3,$4,$5)",[randomUUID(),userId,serverId,interfaceName,peer])
clearPendingFlows(); invalidateFlowCatalogCache(); invalidateFlowAnalyticsCache()
await put(110,"analytics-in",100,"198.18.241.1"); await put(110,"analytics-mixed-in",200,"198.18.241.2")
await put(111,"NEVER_ANALYTICS_HIDDEN_IFACE","9007199254740993","198.18.241.3")
await put(110,"NEVER_ANALYTICS_FUTURE_IFACE",999,"198.18.241.4",new Date(Date.now()+3600000).toISOString())
const topology = await loadFlowTopology([110])
assert.deepEqual([...topology.clientByIface.values()].map(row=>row.userId),[users[0]])
let result = await read()
assert.equal(result.analytics.bytes,300); assert.equal(result.analytics.packets,2); assert.equal(result.live,false); assert.equal(result.forwardingVerified,false)
assert.ok(!JSON.stringify(result).includes("NEVER_")); assert.equal(result.pages.conversationsList.total,2)
const restEngine = await buildFlowAnalytics({serverId:110,minutes:5})
assert.equal(result.analytics.bytes,restEngine.bytes); assert.deepEqual(result.analytics.protocols,restEngine.protocols)
// An unrestricted REST cache contains mixed-user metadata; scoped reads must bypass it.
assert.ok(JSON.stringify(restEngine).includes("NEVER_ANALYTICS_MIXED_USER"))
assert.ok(!JSON.stringify(await read()).includes("NEVER_"))
result = await read({limit:1}); assert.equal(result.analytics.conversationsList.length,1); assert.equal(result.pages.conversationsList.nextOffset,1)
assert.equal((await read({offset:1,limit:1})).analytics.conversationsList.length,1)
assert.equal((await read({interface:"analytics-in"})).analytics.bytes,100)
assert.equal((await read({userId:users[0]})).analytics.bytes,100)
await put(110,"analytics-duplicate-in",50,"198.18.241.1")
assert.equal((await read()).analytics.bytes,300)
assert.equal((await read({dedup:false,excludeMesh:false,excludeOverlay:false})).analytics.bytes,350)
await pool.query("DELETE FROM flow_buckets WHERE server_id=110 AND in_iface='analytics-duplicate-in'")
await assert.rejects(read({userId:users[1]})); await assert.rejects(read({userId:users[2]}))
await assert.rejects(read({serverId:111})); await assert.rejects(read({minutes:1441})); await assert.rejects(read({interface:"__all__"})); await assert.rejects(read({skipHeavy:true}))
await assert.rejects(buildFlowAnalytics({serverId:110,minutes:5},{serverIds:[110],maxRows:1,authorize:async()=>{}}),/предел/)
const cancelled = new AbortController(); cancelled.abort(); await assert.rejects(invokeMcpTool(principal,"mm_netflow_analytics_report_get",{serverId:110},cancelled.signal))
const duringQuery = (match: string, action: () => Promise<unknown>) =>
duringFlowSourceQuery(pool, match, action, () => assert.rejects(read()))
await duringQuery("FROM flow_buckets",async()=>{await pool.query("UPDATE servers SET host='192.0.2.210' WHERE id=110");await pool.query("UPDATE servers SET host='192.0.2.110' WHERE id=110")})
await duringQuery("FROM server_snapshots",async()=>{await pool.query("UPDATE user_interface_bindings SET interface_name='changed-during-read' WHERE user_id=$1",[users[0]])})
await pool.query("UPDATE user_interface_bindings SET interface_name='analytics-in' WHERE user_id=$1",[users[0]])
await duringQuery("FROM flow_buckets",()=>setConnectionStatus(access.connection.id,"paused",owner))
await setConnectionStatus(access.connection.id,"active",owner)
assert.equal((await read()).analytics.bytes,300)
assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_plans WHERE connection_id=$1",[access.connection.id])).rows[0].n,0)
console.log("✓ Scoped NetFlow analytics: REST engine parity, hidden server/user/peer exclusion, cache isolation, bounds/paging/future rows, source ABA/topology drift, cancel/pause and no plans")
} finally {
await setConnectionStatus(access.connection.id,"paused",owner)
await pool.query("DELETE FROM user_interface_bindings WHERE user_id=ANY($1::text[])",[users]); await pool.query("DELETE FROM app_users WHERE id=ANY($1::text[])",[users])
await pool.query("DELETE FROM flow_buckets WHERE server_id IN (110,111)"); await pool.query("DELETE FROM servers WHERE id IN (110,111)")
clearPendingFlows(); invalidateFlowCatalogCache(); invalidateFlowAnalyticsCache(); resetRipeCacheForTests()
}
}