fix: address security review findings, implement real cache clearing, and fix lifecycle promises

This commit is contained in:
syntaxbullet
2026-01-08 21:29:09 +01:00
parent 0f6cce9b6e
commit 19206b5cc7
11 changed files with 176 additions and 47 deletions

View File

@@ -8,6 +8,7 @@ import { startWebServerFromRoot } from "../web/src/server";
await AuroraClient.loadCommands();
await AuroraClient.loadEvents();
await AuroraClient.deployCommands();
await AuroraClient.setupSystemEvents();
console.log("🌐 Starting web server...");

View File

@@ -38,6 +38,15 @@ mock.module("../lib/loaders/EventLoader", () => ({
}));
// Mock dashboard service to prevent network/db calls during event handling
mock.module("@shared/modules/economy/lootdrop.service", () => ({
lootdropService: { clearCaches: mock(async () => { }) }
}));
mock.module("@shared/modules/trade/trade.service", () => ({
tradeService: { clearSessions: mock(() => { }) }
}));
mock.module("@/modules/admin/item_wizard", () => ({
clearDraftSessions: mock(() => { })
}));
mock.module("@shared/modules/dashboard/dashboard.service", () => ({
dashboardService: {
recordEvent: mock(() => Promise.resolve())
@@ -48,11 +57,12 @@ describe("AuroraClient System Events", () => {
let AuroraClient: any;
beforeEach(async () => {
// Clear mocks and re-import client to ensure fresh listeners if possible
// Note: AuroraClient is a singleton, so we mostly reset its state
systemEvents.removeAllListeners();
const module = await import("./BotClient");
AuroraClient = module.AuroraClient;
AuroraClient.maintenanceMode = false;
// MUST call explicitly now
await AuroraClient.setupSystemEvents();
});
/**
@@ -61,14 +71,11 @@ describe("AuroraClient System Events", () => {
*/
test("should toggle maintenanceMode when MAINTENANCE_MODE event is received", async () => {
systemEvents.emit(EVENTS.ACTIONS.MAINTENANCE_MODE, { enabled: true, reason: "Testing" });
// Give event loop time to process
await new Promise(resolve => setTimeout(resolve, 20));
await new Promise(resolve => setTimeout(resolve, 30));
expect(AuroraClient.maintenanceMode).toBe(true);
systemEvents.emit(EVENTS.ACTIONS.MAINTENANCE_MODE, { enabled: false });
await new Promise(resolve => setTimeout(resolve, 20));
await new Promise(resolve => setTimeout(resolve, 30));
expect(AuroraClient.maintenanceMode).toBe(false);
});
@@ -77,16 +84,28 @@ describe("AuroraClient System Events", () => {
* Requirement: loadCommands and deployCommands should be called
*/
test("should reload commands when RELOAD_COMMANDS event is received", async () => {
// Spy on the methods that should be called
const loadSpy = spyOn(AuroraClient, "loadCommands").mockImplementation(() => Promise.resolve());
const deploySpy = spyOn(AuroraClient, "deployCommands").mockImplementation(() => Promise.resolve());
systemEvents.emit(EVENTS.ACTIONS.RELOAD_COMMANDS);
// Wait for async handlers
await new Promise(resolve => setTimeout(resolve, 50));
expect(loadSpy).toHaveBeenCalled();
expect(deploySpy).toHaveBeenCalled();
});
/**
* Test Case: Cache Clearance
* Requirement: Service clear methods should be triggered
*/
test("should trigger service cache clearance when CLEAR_CACHE is received", async () => {
const { lootdropService } = await import("@shared/modules/economy/lootdrop.service");
const { tradeService } = await import("@shared/modules/trade/trade.service");
systemEvents.emit(EVENTS.ACTIONS.CLEAR_CACHE);
await new Promise(resolve => setTimeout(resolve, 50));
expect(lootdropService.clearCaches).toHaveBeenCalled();
expect(tradeService.clearSessions).toHaveBeenCalled();
});
});

View File

@@ -18,13 +18,14 @@ export class Client extends DiscordClient {
this.commands = new Collection<string, Command>();
this.commandLoader = new CommandLoader(this);
this.eventLoader = new EventLoader(this);
this.setupSystemEvents();
}
private setupSystemEvents() {
import("@shared/lib/events").then(({ systemEvents, EVENTS }) => {
public async setupSystemEvents() {
const { systemEvents, EVENTS } = await import("@shared/lib/events");
systemEvents.on(EVENTS.ACTIONS.RELOAD_COMMANDS, async () => {
console.log("🔄 System Action: Reloading commands...");
try {
await this.loadCommands(true);
await this.deployCommands();
@@ -34,17 +35,36 @@ export class Client extends DiscordClient {
message: "Bot: Commands reloaded and redeployed",
icon: "✅"
});
} catch (error) {
console.error("Failed to reload commands:", error);
}
});
systemEvents.on(EVENTS.ACTIONS.CLEAR_CACHE, async () => {
console.log("🧹 System Action: Clearing caches...");
// In a real app, we'd loop through services and clear their internal maps
console.log("<EFBFBD> System Action: Clearing all internal caches...");
try {
// 1. Lootdrop Service
const { lootdropService } = await import("@shared/modules/economy/lootdrop.service");
await lootdropService.clearCaches();
// 2. Trade Service
const { tradeService } = await import("@shared/modules/trade/trade.service");
tradeService.clearSessions();
// 3. Item Wizard
const { clearDraftSessions } = await import("@/modules/admin/item_wizard");
clearDraftSessions();
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
await dashboardService.recordEvent({
type: "success",
message: "Bot: Internal caches cleared",
message: "Bot: All internal caches and sessions cleared",
icon: "🧼"
});
} catch (error) {
console.error("Failed to clear caches:", error);
}
});
systemEvents.on(EVENTS.ACTIONS.MAINTENANCE_MODE, async (data: { enabled: boolean, reason?: string }) => {
@@ -52,7 +72,6 @@ export class Client extends DiscordClient {
console.log(`🛠️ System Action: Maintenance mode ${enabled ? "ON" : "OFF"}${reason ? ` (${reason})` : ""}`);
this.maintenanceMode = enabled;
});
});
}
async loadCommands(reload: boolean = false) {

View File

@@ -241,3 +241,8 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
}
};
export const clearDraftSessions = () => {
draftSession.clear();
console.log("[ItemWizard] All draft item creation sessions cleared.");
};

View File

@@ -7,6 +7,7 @@ const envSchema = z.object({
DATABASE_URL: z.string().min(1, "Database URL is required"),
PORT: z.coerce.number().default(3000),
HOST: z.string().default("127.0.0.1"),
ADMIN_TOKEN: z.string().min(8, "ADMIN_TOKEN must be at least 8 characters"),
});
const parsedEnv = envSchema.safeParse(process.env);

View File

@@ -59,6 +59,12 @@ export const ClientStatsSchema = z.object({
export type ClientStats = z.infer<typeof ClientStatsSchema>;
// Action Schemas
export const MaintenanceModeSchema = z.object({
enabled: z.boolean(),
reason: z.string().optional(),
});
// WebSocket Message Schemas
export const WsMessageSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("PING") }),

View File

@@ -163,6 +163,11 @@ class LootdropService {
return { success: false, error: "An error occurred while processing the reward." };
}
}
public async clearCaches() {
this.channelActivity.clear();
this.channelCooldowns.clear();
console.log("[LootdropService] Caches cleared via administrative action.");
}
}
export const lootdropService = new LootdropService();

View File

@@ -196,5 +196,10 @@ export const tradeService = {
});
tradeService.endSession(threadId);
},
clearSessions: () => {
sessions.clear();
console.log("[TradeService] All active trade sessions cleared.");
}
};

View File

@@ -10,6 +10,14 @@ interface ControlPanelProps {
maintenanceMode: boolean;
}
declare global {
interface Window {
AURORA_ENV?: {
ADMIN_TOKEN: string;
};
}
}
/**
* ControlPanel component provides quick administrative actions for the bot.
* Integrated with the premium glassmorphic theme.
@@ -20,12 +28,16 @@ export function ControlPanel({ maintenanceMode }: ControlPanelProps) {
/**
* Handles triggering an administrative action via the API
*/
const handleAction = async (action: string, payload?: any) => {
const handleAction = async (action: string, payload?: Record<string, unknown>) => {
setLoading(action);
try {
const token = window.AURORA_ENV?.ADMIN_TOKEN;
const response = await fetch(`/api/actions/${action}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
body: payload ? JSON.stringify(payload) : undefined,
});
if (!response.ok) throw new Error(`Action ${action} failed`);

View File

@@ -51,18 +51,7 @@ mock.module("../../bot/lib/clientStats", () => ({
})),
}));
// 3. Mock System Events
mock.module("@shared/lib/events", () => ({
systemEvents: {
on: mock(() => { }),
emit: mock(() => { }),
},
EVENTS: {
DASHBOARD: {
NEW_EVENT: "dashboard:new_event",
}
}
}));
// 3. System Events (No mock needed, use real events)
describe("WebServer Security & Limits", () => {
const port = 3001;
@@ -110,4 +99,45 @@ describe("WebServer Security & Limits", () => {
const data = (await response.json()) as { status: string };
expect(data.status).toBe("ok");
});
describe("Administrative Actions Authorization", () => {
test("should reject administrative actions without token", async () => {
const response = await fetch(`http://localhost:${port}/api/actions/reload-commands`, {
method: "POST"
});
expect(response.status).toBe(401);
});
test("should reject administrative actions with invalid token", async () => {
const response = await fetch(`http://localhost:${port}/api/actions/reload-commands`, {
method: "POST",
headers: { "Authorization": "Bearer wrong-token" }
});
expect(response.status).toBe(401);
});
test("should accept administrative actions with valid token", async () => {
const { env } = await import("@shared/lib/env");
const response = await fetch(`http://localhost:${port}/api/actions/reload-commands`, {
method: "POST",
headers: { "Authorization": `Bearer ${env.ADMIN_TOKEN}` }
});
expect(response.status).toBe(200);
});
test("should reject maintenance mode with invalid payload", async () => {
const { env } = await import("@shared/lib/env");
const response = await fetch(`http://localhost:${port}/api/actions/maintenance-mode`, {
method: "POST",
headers: {
"Authorization": `Bearer ${env.ADMIN_TOKEN}`,
"Content-Type": "application/json"
},
body: JSON.stringify({ not_enabled: true }) // Wrong field
});
expect(response.status).toBe(400);
const data = await response.json() as { error: string };
expect(data.error).toBe("Invalid payload");
});
});
});

View File

@@ -100,7 +100,16 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
// Administrative Actions
if (url.pathname.startsWith("/api/actions/") && req.method === "POST") {
try {
// Security Check: Token-based authentication
const { env } = await import("@shared/lib/env");
const authHeader = req.headers.get("Authorization");
if (authHeader !== `Bearer ${env.ADMIN_TOKEN}`) {
console.warn(`⚠️ [API] Unauthorized administrative action attempt from ${req.headers.get("x-forwarded-for") || "unknown"}`);
return new Response("Unauthorized", { status: 401 });
}
const { actionService } = await import("@shared/modules/admin/action.service");
const { MaintenanceModeSchema } = await import("@shared/modules/dashboard/dashboard.types");
if (url.pathname === "/api/actions/reload-commands") {
const result = await actionService.reloadCommands();
@@ -113,8 +122,14 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
}
if (url.pathname === "/api/actions/maintenance-mode") {
const body = await req.json() as { enabled: boolean; reason?: string };
const result = await actionService.toggleMaintenanceMode(body.enabled, body.reason);
const rawBody = await req.json();
const parsed = MaintenanceModeSchema.safeParse(rawBody);
if (!parsed.success) {
return Response.json({ error: "Invalid payload", issues: parsed.error.issues }, { status: 400 });
}
const result = await actionService.toggleMaintenanceMode(parsed.data.enabled, parsed.data.reason);
return Response.json(result);
}
} catch (error) {
@@ -141,19 +156,30 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
const fileRef = Bun.file(safePath);
if (await fileRef.exists()) {
// If serving index.html, inject env vars for frontend
if (pathName === "/index.html") {
let html = await fileRef.text();
const { env } = await import("@shared/lib/env");
const envScript = `<script>window.AURORA_ENV = { ADMIN_TOKEN: "${env.ADMIN_TOKEN}" };</script>`;
html = html.replace("</head>", `${envScript}</head>`);
return new Response(html, { headers: { "Content-Type": "text/html" } });
}
return new Response(fileRef);
}
// SPA Fallback: Serve index.html for unknown non-file routes
// If the path looks like a file (has extension), return 404
// Otherwise serve index.html
const parts = pathName.split("/");
const lastPart = parts[parts.length - 1];
if (lastPart?.includes(".")) {
return new Response("Not Found", { status: 404 });
}
return new Response(Bun.file(join(distDir, "index.html")));
const indexFile = Bun.file(join(distDir, "index.html"));
let indexHtml = await indexFile.text();
const { env: sharedEnv } = await import("@shared/lib/env");
const script = `<script>window.AURORA_ENV = { ADMIN_TOKEN: "${sharedEnv.ADMIN_TOKEN}" };</script>`;
indexHtml = indexHtml.replace("</head>", `${script}</head>`);
return new Response(indexHtml, { headers: { "Content-Type": "text/html" } });
},
websocket: {