fix(dash): address safety constraints, validation, and test quality issues

This commit is contained in:
syntaxbullet
2026-01-08 21:08:47 +01:00
parent 1251df286e
commit 8253de9f73
4 changed files with 191 additions and 64 deletions

View File

@@ -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");
}
}); });
}); });
}); });

View File

@@ -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
View 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");
});
});

View File

@@ -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,