forked from syntaxbullet/AuroraBot-discord
fix(dash): address safety constraints, validation, and test quality issues
This commit is contained in:
75
web/src/server.test.ts
Normal file
75
web/src/server.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { describe, test, expect, afterAll, mock } from "bun:test";
|
||||
|
||||
// Mock the services directly to stay focused on server limits
|
||||
mock.module("@shared/modules/dashboard/dashboard.service", () => ({
|
||||
dashboardService: {
|
||||
getActiveUserCount: mock(() => Promise.resolve(5)),
|
||||
getTotalUserCount: mock(() => Promise.resolve(10)),
|
||||
getEconomyStats: mock(() => Promise.resolve({ totalWealth: 1000n, avgLevel: 5, topStreak: 2 })),
|
||||
getRecentEvents: mock(() => Promise.resolve([])),
|
||||
}
|
||||
}));
|
||||
|
||||
mock.module("../../bot/lib/clientStats", () => ({
|
||||
getClientStats: mock(() => ({
|
||||
bot: { name: "TestBot", avatarUrl: null },
|
||||
guilds: 5,
|
||||
ping: 42,
|
||||
cachedUsers: 100,
|
||||
commandsRegistered: 10,
|
||||
uptime: 3600,
|
||||
lastCommandTimestamp: Date.now(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Ensure @shared/lib/events is mocked if needed
|
||||
mock.module("@shared/lib/events", () => ({
|
||||
systemEvents: { on: mock(() => { }) },
|
||||
EVENTS: { DASHBOARD: { NEW_EVENT: "dashboard:new_event" } }
|
||||
}));
|
||||
|
||||
import { createWebServer } from "./server";
|
||||
|
||||
describe("WebServer Security & Limits", () => {
|
||||
const port = 3001;
|
||||
let serverInstance: any;
|
||||
|
||||
afterAll(async () => {
|
||||
if (serverInstance) {
|
||||
await serverInstance.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("should reject more than 10 concurrent WebSocket connections", async () => {
|
||||
serverInstance = await createWebServer({ port, hostname: "localhost" });
|
||||
const wsUrl = `ws://localhost:${port}/ws`;
|
||||
const sockets: WebSocket[] = [];
|
||||
|
||||
try {
|
||||
// Attempt to open 12 connections (limit is 10)
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
sockets.push(ws);
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
|
||||
// Give connections time to settle
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Should be exactly 10 or less
|
||||
expect(serverInstance.server.pendingWebSockets).toBeLessThanOrEqual(10);
|
||||
} finally {
|
||||
sockets.forEach(s => s.close());
|
||||
}
|
||||
});
|
||||
|
||||
test("should return 200 for health check", async () => {
|
||||
if (!serverInstance) {
|
||||
serverInstance = await createWebServer({ port, hostname: "localhost" });
|
||||
}
|
||||
const response = await fetch(`http://localhost:${port}/api/health`);
|
||||
expect(response.status).toBe(200);
|
||||
const data = await response.json();
|
||||
expect(data.status).toBe("ok");
|
||||
});
|
||||
});
|
||||
@@ -51,6 +51,11 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
|
||||
}
|
||||
}
|
||||
|
||||
// Configuration constants
|
||||
const MAX_CONNECTIONS = 10;
|
||||
const MAX_PAYLOAD_BYTES = 16384; // 16KB
|
||||
const IDLE_TIMEOUT_SECONDS = 60;
|
||||
|
||||
// Interval for broadcasting stats to all connected WS clients
|
||||
let statsBroadcastInterval: Timer | undefined;
|
||||
|
||||
@@ -62,6 +67,13 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
|
||||
|
||||
// Upgrade to WebSocket
|
||||
if (url.pathname === "/ws") {
|
||||
// Security Check: limit concurrent connections
|
||||
const currentConnections = server.pendingWebSockets;
|
||||
if (currentConnections >= MAX_CONNECTIONS) {
|
||||
console.warn(`⚠️ [WS] Connection rejected: limit reached (${currentConnections}/${MAX_CONNECTIONS})`);
|
||||
return new Response("Connection limit reached", { status: 429 });
|
||||
}
|
||||
|
||||
const success = server.upgrade(req);
|
||||
if (success) return undefined;
|
||||
return new Response("WebSocket upgrade failed", { status: 400 });
|
||||
@@ -137,30 +149,43 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
|
||||
}, 5000);
|
||||
}
|
||||
},
|
||||
message(ws, message) {
|
||||
// Handle messages if needed (e.g. heartbeat)
|
||||
async message(ws, message) {
|
||||
try {
|
||||
const data = JSON.parse(message.toString());
|
||||
if (data.type === "PING") ws.send(JSON.stringify({ type: "PONG" }));
|
||||
} catch (e) { }
|
||||
const { WsMessageSchema } = await import("@shared/modules/dashboard/dashboard.types");
|
||||
const parsed = WsMessageSchema.safeParse(JSON.parse(message.toString()));
|
||||
|
||||
if (!parsed.success) {
|
||||
console.error("❌ [WS] Invalid message format:", parsed.error.issues);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.data.type === "PING") {
|
||||
ws.send(JSON.stringify({ type: "PONG" }));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("❌ [WS] Failed to parse message:", e);
|
||||
}
|
||||
},
|
||||
close(ws) {
|
||||
ws.unsubscribe("dashboard");
|
||||
console.log(`🔌 [WS] Client disconnected.`);
|
||||
console.log(`🔌 [WS] Client disconnected. Total remaining: ${server.pendingWebSockets}`);
|
||||
|
||||
// Stop broadcast interval if no clients left
|
||||
if (server.pendingWebSockets === 0 && statsBroadcastInterval) {
|
||||
clearInterval(statsBroadcastInterval);
|
||||
statsBroadcastInterval = undefined;
|
||||
}
|
||||
}
|
||||
},
|
||||
maxPayloadLength: MAX_PAYLOAD_BYTES,
|
||||
idleTimeout: IDLE_TIMEOUT_SECONDS,
|
||||
},
|
||||
|
||||
development: isDev,
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper to fetch full dashboard stats object
|
||||
* Helper to fetch full dashboard stats object.
|
||||
* Unified for both HTTP API and WebSocket broadcasts.
|
||||
*/
|
||||
async function getFullDashboardStats() {
|
||||
// Import services (dynamic to avoid circular deps)
|
||||
@@ -189,7 +214,7 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
|
||||
},
|
||||
recentEvents: recentEvents.map(event => ({
|
||||
...event,
|
||||
timestamp: event.timestamp.toISOString(),
|
||||
timestamp: event.timestamp instanceof Date ? event.timestamp.toISOString() : event.timestamp,
|
||||
})),
|
||||
uptime: clientStats.uptime,
|
||||
lastCommandTimestamp: clientStats.lastCommandTimestamp,
|
||||
|
||||
Reference in New Issue
Block a user