fix(dash): address safety constraints, validation, and test quality issues
This commit is contained in:
@@ -148,15 +148,15 @@ describe("dashboardService", () => {
|
|||||||
|
|
||||||
expect(events).toHaveLength(2);
|
expect(events).toHaveLength(2);
|
||||||
// Should be sorted by timestamp (newest first)
|
// Should be sorted by timestamp (newest first)
|
||||||
expect(events[0]?.timestamp.getTime()).toBeGreaterThanOrEqual(
|
const t0 = events[0]?.timestamp instanceof Date ? events[0].timestamp.getTime() : new Date(events[0]?.timestamp ?? 0).getTime();
|
||||||
events[1]?.timestamp.getTime() ?? 0
|
const t1 = events[1]?.timestamp instanceof Date ? events[1].timestamp.getTime() : new Date(events[1]?.timestamp ?? 0).getTime();
|
||||||
);
|
expect(t0).toBeGreaterThanOrEqual(t1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("recordEvent", () => {
|
describe("recordEvent", () => {
|
||||||
test("should emit NEW_EVENT to systemEvents", async () => {
|
test("should emit NEW_EVENT to systemEvents", async () => {
|
||||||
const mockEmit = mock(() => { });
|
const mockEmit = mock((_event: string, _data: any) => { });
|
||||||
|
|
||||||
mock.module("@shared/lib/events", () => ({
|
mock.module("@shared/lib/events", () => ({
|
||||||
systemEvents: {
|
systemEvents: {
|
||||||
@@ -176,10 +176,17 @@ describe("dashboardService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(mockEmit).toHaveBeenCalled();
|
expect(mockEmit).toHaveBeenCalled();
|
||||||
const [eventName, data] = mockEmit.mock.calls[0] as any;
|
const calls = mockEmit.mock.calls;
|
||||||
expect(eventName).toBe("dashboard:new_event");
|
if (calls.length > 0 && calls[0]) {
|
||||||
|
expect(calls[0][0]).toBe("dashboard:new_event");
|
||||||
|
const data = calls[0][1] as { message: string, timestamp: string };
|
||||||
expect(data.message).toBe("Test Event");
|
expect(data.message).toBe("Test Event");
|
||||||
expect(data.timestamp).toBeDefined();
|
expect(data.timestamp).toBeDefined();
|
||||||
|
// Verify it's an ISO string
|
||||||
|
expect(() => new Date(data.timestamp).toISOString()).not.toThrow();
|
||||||
|
} else {
|
||||||
|
throw new Error("mockEmit was not called with expected arguments");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,49 +1,69 @@
|
|||||||
export interface DashboardStats {
|
import { z } from "zod";
|
||||||
bot: {
|
|
||||||
name: string;
|
|
||||||
avatarUrl: string | null;
|
|
||||||
};
|
|
||||||
guilds: {
|
|
||||||
count: number;
|
|
||||||
changeFromLastMonth?: number;
|
|
||||||
};
|
|
||||||
users: {
|
|
||||||
active: number;
|
|
||||||
total: number;
|
|
||||||
changePercentFromLastMonth?: number;
|
|
||||||
};
|
|
||||||
commands: {
|
|
||||||
total: number;
|
|
||||||
changePercentFromLastMonth?: number;
|
|
||||||
};
|
|
||||||
ping: {
|
|
||||||
avg: number;
|
|
||||||
changeFromLastHour?: number;
|
|
||||||
};
|
|
||||||
economy: {
|
|
||||||
totalWealth: string; // bigint as string for JSON
|
|
||||||
avgLevel: number;
|
|
||||||
topStreak: number;
|
|
||||||
};
|
|
||||||
recentEvents: RecentEvent[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RecentEvent {
|
export const RecentEventSchema = z.object({
|
||||||
type: 'success' | 'error' | 'info';
|
type: z.enum(['success', 'error', 'info']),
|
||||||
message: string;
|
message: z.string(),
|
||||||
timestamp: Date;
|
timestamp: z.union([z.date(), z.string().datetime()]),
|
||||||
icon?: string;
|
icon: z.string().optional(),
|
||||||
}
|
});
|
||||||
|
|
||||||
export interface ClientStats {
|
export type RecentEvent = z.infer<typeof RecentEventSchema>;
|
||||||
bot: {
|
|
||||||
name: string;
|
export const DashboardStatsSchema = z.object({
|
||||||
avatarUrl: string | null;
|
bot: z.object({
|
||||||
};
|
name: z.string(),
|
||||||
guilds: number;
|
avatarUrl: z.string().nullable(),
|
||||||
ping: number;
|
}),
|
||||||
cachedUsers: number;
|
guilds: z.object({
|
||||||
commandsRegistered: number;
|
count: z.number(),
|
||||||
uptime: number;
|
changeFromLastMonth: z.number().optional(),
|
||||||
lastCommandTimestamp: number | null;
|
}),
|
||||||
}
|
users: z.object({
|
||||||
|
active: z.number(),
|
||||||
|
total: z.number(),
|
||||||
|
changePercentFromLastMonth: z.number().optional(),
|
||||||
|
}),
|
||||||
|
commands: z.object({
|
||||||
|
total: z.number(),
|
||||||
|
changePercentFromLastMonth: z.number().optional(),
|
||||||
|
}),
|
||||||
|
ping: z.object({
|
||||||
|
avg: z.number(),
|
||||||
|
changeFromLastHour: z.number().optional(),
|
||||||
|
}),
|
||||||
|
economy: z.object({
|
||||||
|
totalWealth: z.string(),
|
||||||
|
avgLevel: z.number(),
|
||||||
|
topStreak: z.number(),
|
||||||
|
}),
|
||||||
|
recentEvents: z.array(RecentEventSchema),
|
||||||
|
uptime: z.number(),
|
||||||
|
lastCommandTimestamp: z.number().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type DashboardStats = z.infer<typeof DashboardStatsSchema>;
|
||||||
|
|
||||||
|
export const ClientStatsSchema = z.object({
|
||||||
|
bot: z.object({
|
||||||
|
name: z.string(),
|
||||||
|
avatarUrl: z.string().nullable(),
|
||||||
|
}),
|
||||||
|
guilds: z.number(),
|
||||||
|
ping: z.number(),
|
||||||
|
cachedUsers: z.number(),
|
||||||
|
commandsRegistered: z.number(),
|
||||||
|
uptime: z.number(),
|
||||||
|
lastCommandTimestamp: z.number().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ClientStats = z.infer<typeof ClientStatsSchema>;
|
||||||
|
|
||||||
|
// WebSocket Message Schemas
|
||||||
|
export const WsMessageSchema = z.discriminatedUnion("type", [
|
||||||
|
z.object({ type: z.literal("PING") }),
|
||||||
|
z.object({ type: z.literal("PONG") }),
|
||||||
|
z.object({ type: z.literal("STATS_UPDATE"), data: DashboardStatsSchema }),
|
||||||
|
z.object({ type: z.literal("NEW_EVENT"), data: RecentEventSchema }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type WsMessage = z.infer<typeof WsMessageSchema>;
|
||||||
|
|||||||
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
|
// Interval for broadcasting stats to all connected WS clients
|
||||||
let statsBroadcastInterval: Timer | undefined;
|
let statsBroadcastInterval: Timer | undefined;
|
||||||
|
|
||||||
@@ -62,6 +67,13 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
|
|||||||
|
|
||||||
// Upgrade to WebSocket
|
// Upgrade to WebSocket
|
||||||
if (url.pathname === "/ws") {
|
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);
|
const success = server.upgrade(req);
|
||||||
if (success) return undefined;
|
if (success) return undefined;
|
||||||
return new Response("WebSocket upgrade failed", { status: 400 });
|
return new Response("WebSocket upgrade failed", { status: 400 });
|
||||||
@@ -137,30 +149,43 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
|
|||||||
}, 5000);
|
}, 5000);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
message(ws, message) {
|
async message(ws, message) {
|
||||||
// Handle messages if needed (e.g. heartbeat)
|
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(message.toString());
|
const { WsMessageSchema } = await import("@shared/modules/dashboard/dashboard.types");
|
||||||
if (data.type === "PING") ws.send(JSON.stringify({ type: "PONG" }));
|
const parsed = WsMessageSchema.safeParse(JSON.parse(message.toString()));
|
||||||
} catch (e) { }
|
|
||||||
|
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) {
|
close(ws) {
|
||||||
ws.unsubscribe("dashboard");
|
ws.unsubscribe("dashboard");
|
||||||
console.log(`🔌 [WS] Client disconnected.`);
|
console.log(`🔌 [WS] Client disconnected. Total remaining: ${server.pendingWebSockets}`);
|
||||||
|
|
||||||
// Stop broadcast interval if no clients left
|
// Stop broadcast interval if no clients left
|
||||||
if (server.pendingWebSockets === 0 && statsBroadcastInterval) {
|
if (server.pendingWebSockets === 0 && statsBroadcastInterval) {
|
||||||
clearInterval(statsBroadcastInterval);
|
clearInterval(statsBroadcastInterval);
|
||||||
statsBroadcastInterval = undefined;
|
statsBroadcastInterval = undefined;
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
maxPayloadLength: MAX_PAYLOAD_BYTES,
|
||||||
|
idleTimeout: IDLE_TIMEOUT_SECONDS,
|
||||||
},
|
},
|
||||||
|
|
||||||
development: isDev,
|
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() {
|
async function getFullDashboardStats() {
|
||||||
// Import services (dynamic to avoid circular deps)
|
// Import services (dynamic to avoid circular deps)
|
||||||
@@ -189,7 +214,7 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
|
|||||||
},
|
},
|
||||||
recentEvents: recentEvents.map(event => ({
|
recentEvents: recentEvents.map(event => ({
|
||||||
...event,
|
...event,
|
||||||
timestamp: event.timestamp.toISOString(),
|
timestamp: event.timestamp instanceof Date ? event.timestamp.toISOString() : event.timestamp,
|
||||||
})),
|
})),
|
||||||
uptime: clientStats.uptime,
|
uptime: clientStats.uptime,
|
||||||
lastCommandTimestamp: clientStats.lastCommandTimestamp,
|
lastCommandTimestamp: clientStats.lastCommandTimestamp,
|
||||||
|
|||||||
Reference in New Issue
Block a user