Add auth checks for user routes and dashboard state
Some checks failed
Deploy to Production / test (push) Failing after 33s

- tighten route authorization and schema handling
- update user route tests and server coverage
- refresh player dashboard behavior
This commit is contained in:
syntaxbullet
2026-04-09 20:42:32 +02:00
parent bdfe0d1594
commit 8369d10bab
20 changed files with 471 additions and 66 deletions

View File

@@ -0,0 +1,114 @@
import { beforeEach, describe, expect, it, mock } from "bun:test";
let currentSession: { discordId: string; username: string; role: "admin" | "player"; expiresAt: number } | null = null;
mock.module("./auth.routes", () => ({
authRoutes: { name: "auth", handler: () => null },
getSession: () => currentSession,
}));
mock.module("./health.routes", () => ({
healthRoutes: {
name: "health",
handler: ({ pathname }: { pathname: string }) =>
pathname === "/api/health"
? Response.json({ status: "ok" }, { status: 200 })
: null,
},
}));
mock.module("./stats.routes", () => ({
statsRoutes: {
name: "stats",
handler: ({ pathname }: { pathname: string }) =>
pathname === "/api/stats"
? Response.json({ ok: true }, { status: 200 })
: null,
},
}));
mock.module("./actions.routes", () => ({ actionsRoutes: { name: "actions", handler: () => null } }));
mock.module("./quests.routes", () => ({ questsRoutes: { name: "quests", handler: () => null } }));
mock.module("./settings.routes", () => ({ settingsRoutes: { name: "settings", handler: () => null } }));
mock.module("./guild-settings.routes", () => ({ guildSettingsRoutes: { name: "guild-settings", handler: () => null } }));
mock.module("./items.routes", () => ({ itemsRoutes: { name: "items", handler: () => null } }));
mock.module("./classes.routes", () => ({ classesRoutes: { name: "classes", handler: () => null } }));
mock.module("./moderation.routes", () => ({ moderationRoutes: { name: "moderation", handler: () => null } }));
mock.module("./transactions.routes", () => ({ transactionsRoutes: { name: "transactions", handler: () => null } }));
mock.module("./lootdrops.routes", () => ({ lootdropsRoutes: { name: "lootdrops", handler: () => null } }));
mock.module("./assets.routes", () => ({ assetsRoutes: { name: "assets", handler: () => null } }));
mock.module("@shared/modules/user/user.service", () => ({
userService: {
getUserById: async (id: string) => ({ id, username: `user-${id}` }),
},
}));
mock.module("@shared/modules/inventory/inventory.service", () => ({
inventoryService: {
getInventory: async (id: string) => [{ userId: id, itemId: 1, quantity: 1n }],
},
}));
mock.module("@shared/lib/logger", () => ({
logger: {
error: () => { },
info: () => { },
warn: () => { },
debug: () => { },
},
}));
import { handleRequest } from "./index";
describe("Route Authorization", () => {
beforeEach(() => {
currentSession = null;
});
it("rejects unauthenticated protected API requests", async () => {
const url = new URL("http://localhost/api/users/123");
const res = await handleRequest(new Request(url, { method: "GET" }), url);
expect(res?.status).toBe(401);
});
it("blocks players from admin user routes", async () => {
currentSession = {
discordId: "123",
username: "player",
role: "player",
expiresAt: Date.now() + 60_000,
};
const url = new URL("http://localhost/api/users/456");
const res = await handleRequest(new Request(url, { method: "GET" }), url);
expect(res?.status).toBe(403);
});
it("allows players to access self-service API routes", async () => {
currentSession = {
discordId: "123",
username: "player",
role: "player",
expiresAt: Date.now() + 60_000,
};
const url = new URL("http://localhost/api/me/inventory");
const res = await handleRequest(new Request(url, { method: "GET" }), url);
expect(res?.status).toBe(200);
});
it("allows admins to access admin user routes", async () => {
currentSession = {
discordId: "1",
username: "admin",
role: "admin",
expiresAt: Date.now() + 60_000,
};
const url = new URL("http://localhost/api/users/456");
const res = await handleRequest(new Request(url, { method: "GET" }), url);
expect(res?.status).toBe(200);
});
});

View File

@@ -4,7 +4,7 @@
*/
import type { RouteContext, RouteModule } from "./types";
import { authRoutes, isAuthenticated, getSession } from "./auth.routes";
import { authRoutes, getSession } from "./auth.routes";
import { healthRoutes } from "./health.routes";
import { statsRoutes } from "./stats.routes";
import { actionsRoutes } from "./actions.routes";
@@ -75,14 +75,11 @@ export async function handleRequest(req: Request, url: URL): Promise<Response |
return errorResponse("Unauthorized", 401);
}
// Admin-only routes: everything except stats and own user data
const playerAllowedPrefixes = ["/api/stats", "/api/health"];
// Player routes are explicitly allow-listed. Everything else is admin-only.
const playerAllowedPrefixes = ["/api/stats", "/api/health", "/api/me"];
const isPlayerAllowed = playerAllowedPrefixes.some(p => ctx.pathname.startsWith(p));
// Players can access their own user data
const isOwnUserRoute = ctx.pathname.match(/^\/api\/users\/\d+/) && session.role === "player";
if (session.role === "player" && !isPlayerAllowed && !isOwnUserRoute) {
if (session.role === "player" && !isPlayerAllowed) {
return errorResponse("Admin access required", 403);
}
}

View File

@@ -110,7 +110,7 @@ export const UpdateUserSchema = z.object({
dailyStreak: z.coerce.number().int().min(0).optional(),
isActive: z.boolean().optional(),
settings: z.record(z.string(), z.any()).optional(),
classId: z.union([z.string(), z.number()]).optional(),
classId: z.union([z.string(), z.number()]).nullable().optional(),
});
/**

View File

@@ -0,0 +1,140 @@
import { beforeEach, describe, expect, it, mock } from "bun:test";
let currentSession: { discordId: string; username: string; role: "admin" | "player"; expiresAt: number } | null = null;
const getUserById = mock(async (id: string) => ({
id,
username: id === "123" ? "player-one" : "user",
level: 5,
xp: 100n,
balance: 250n,
className: null,
}));
const updateUser = mock(async (id: string, data: Record<string, unknown>) => ({
id,
...data,
}));
const getInventory = mock(async (id: string) => [{ userId: id, itemId: 1, quantity: 2n }]);
const addItem = mock(async (userId: string, itemId: number, quantity: bigint) => ({ userId, itemId, quantity }));
const removeItem = mock(async () => undefined);
mock.module("./auth.routes", () => ({
getSession: () => currentSession,
}));
mock.module("@shared/modules/user/user.service", () => ({
userService: {
getUserById,
updateUser,
},
}));
mock.module("@shared/modules/inventory/inventory.service", () => ({
inventoryService: {
getInventory,
addItem,
removeItem,
},
}));
mock.module("@shared/lib/logger", () => ({
logger: {
error: () => { },
info: () => { },
warn: () => { },
debug: () => { },
},
}));
import { usersRoutes } from "./users.routes";
describe("Users Routes", () => {
beforeEach(() => {
currentSession = {
discordId: "123",
username: "player",
role: "player",
expiresAt: Date.now() + 60_000,
};
getUserById.mockClear();
updateUser.mockClear();
getInventory.mockClear();
addItem.mockClear();
removeItem.mockClear();
});
it("serves the authenticated user through /api/me", async () => {
const url = new URL("http://localhost/api/me");
const res = await usersRoutes.handler({
req: new Request(url, { method: "GET" }),
url,
method: "GET",
pathname: "/api/me",
});
expect(res?.status).toBe(200);
expect(getUserById).toHaveBeenCalledWith("123");
});
it("serves the authenticated user's inventory through /api/me/inventory", async () => {
const url = new URL("http://localhost/api/me/inventory");
const res = await usersRoutes.handler({
req: new Request(url, { method: "GET" }),
url,
method: "GET",
pathname: "/api/me/inventory",
});
expect(res?.status).toBe(200);
expect(getInventory).toHaveBeenCalledWith("123");
});
it("validates user updates before calling the service", async () => {
const url = new URL("http://localhost/api/users/123");
const res = await usersRoutes.handler({
req: new Request(url, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ level: -1 }),
}),
url,
method: "PUT",
pathname: "/api/users/123",
});
expect(res?.status).toBe(400);
expect(updateUser).not.toHaveBeenCalled();
});
it("validates inventory additions before calling the service", async () => {
const url = new URL("http://localhost/api/users/123/inventory");
const res = await usersRoutes.handler({
req: new Request(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ itemId: 1, quantity: 0 }),
}),
url,
method: "POST",
pathname: "/api/users/123/inventory",
});
expect(res?.status).toBe(400);
expect(addItem).not.toHaveBeenCalled();
});
it("validates inventory removal query params before calling the service", async () => {
const url = new URL("http://localhost/api/users/123/inventory/1?amount=0");
const res = await usersRoutes.handler({
req: new Request(url, { method: "DELETE" }),
url,
method: "DELETE",
pathname: "/api/users/123/inventory/1",
});
expect(res?.status).toBe(400);
expect(removeItem).not.toHaveBeenCalled();
});
});

View File

@@ -8,16 +8,19 @@ import {
jsonResponse,
errorResponse,
parseBody,
parseIdFromPath,
parseQuery,
parseStringIdFromPath,
withErrorHandling
} from "./utils";
import { UpdateUserSchema, InventoryAddSchema } from "./schemas";
import { InventoryAddSchema, InventoryRemoveQuerySchema, UpdateUserSchema, UserQuerySchema } from "./schemas";
import { getSession } from "./auth.routes";
/**
* Users routes handler.
*
* Endpoints:
* - GET /api/me - Get current authenticated user
* - GET /api/me/inventory - Get current authenticated user's inventory
* - GET /api/users - List users with filters
* - GET /api/users/:id - Get single user
* - PUT /api/users/:id - Update user
@@ -30,6 +33,37 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
// Only handle requests to /api/users*
if (!pathname.startsWith("/api/users")) {
if (pathname === "/api/me" && method === "GET") {
return withErrorHandling(async () => {
const session = getSession(req);
if (!session) {
return errorResponse("Unauthorized", 401);
}
const { userService } = await import("@shared/modules/user/user.service");
const user = await userService.getUserById(session.discordId);
if (!user) {
return errorResponse("User not found", 404);
}
return jsonResponse(user);
}, "fetch current user");
}
if (pathname === "/api/me/inventory" && method === "GET") {
return withErrorHandling(async () => {
const session = getSession(req);
if (!session) {
return errorResponse("Unauthorized", 401);
}
const { inventoryService } = await import("@shared/modules/inventory/inventory.service");
const inventory = await inventoryService.getInventory(session.discordId);
return jsonResponse({ inventory });
}, "fetch current user inventory");
}
return null;
}
@@ -55,12 +89,12 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
const { users } = await import("@shared/db/schema");
const { DrizzleClient } = await import("@shared/db/DrizzleClient");
const { ilike, desc, asc, sql } = await import("drizzle-orm");
const queryParams = parseQuery(url, UserQuerySchema);
if (queryParams instanceof Response) {
return queryParams;
}
const search = url.searchParams.get("search") || undefined;
const sortBy = url.searchParams.get("sortBy") || "balance";
const sortOrder = url.searchParams.get("sortOrder") || "desc";
const limit = url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 50;
const offset = url.searchParams.get("offset") ? parseInt(url.searchParams.get("offset")!) : 0;
const { search, sortBy, sortOrder, limit, offset } = queryParams;
let query = DrizzleClient.select().from(users);
@@ -146,7 +180,10 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
return withErrorHandling(async () => {
const { userService } = await import("@shared/modules/user/user.service");
const data = await req.json() as Record<string, any>;
const parsed = await parseBody(req, UpdateUserSchema);
if (parsed instanceof Response) {
return parsed;
}
const existing = await userService.getUserById(id);
if (!existing) {
@@ -155,14 +192,16 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
// Build update data (only allow safe fields)
const updateData: any = {};
if (data.username !== undefined) updateData.username = data.username;
if (data.balance !== undefined) updateData.balance = BigInt(data.balance);
if (data.xp !== undefined) updateData.xp = BigInt(data.xp);
if (data.level !== undefined) updateData.level = parseInt(data.level);
if (data.dailyStreak !== undefined) updateData.dailyStreak = parseInt(data.dailyStreak);
if (data.isActive !== undefined) updateData.isActive = Boolean(data.isActive);
if (data.settings !== undefined) updateData.settings = data.settings;
if (data.classId !== undefined) updateData.classId = BigInt(data.classId);
if (parsed.username !== undefined) updateData.username = parsed.username;
if (parsed.balance !== undefined) updateData.balance = BigInt(parsed.balance);
if (parsed.xp !== undefined) updateData.xp = BigInt(parsed.xp);
if (parsed.level !== undefined) updateData.level = parsed.level;
if (parsed.dailyStreak !== undefined) updateData.dailyStreak = parsed.dailyStreak;
if (parsed.isActive !== undefined) updateData.isActive = parsed.isActive;
if (parsed.settings !== undefined) updateData.settings = parsed.settings;
if (parsed.classId !== undefined) {
updateData.classId = parsed.classId === null ? null : BigInt(parsed.classId);
}
const updatedUser = await userService.updateUser(id, updateData);
return jsonResponse({ success: true, user: updatedUser });
@@ -215,13 +254,12 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
return withErrorHandling(async () => {
const { inventoryService } = await import("@shared/modules/inventory/inventory.service");
const data = await req.json() as Record<string, any>;
if (!data.itemId || !data.quantity) {
return errorResponse("Missing required fields: itemId, quantity", 400);
const parsed = await parseBody(req, InventoryAddSchema);
if (parsed instanceof Response) {
return parsed;
}
const entry = await inventoryService.addItem(id, data.itemId, BigInt(data.quantity));
const entry = await inventoryService.addItem(id, parsed.itemId, BigInt(parsed.quantity));
return jsonResponse({ success: true, entry }, 201);
}, "add item to inventory");
}
@@ -245,11 +283,12 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
return withErrorHandling(async () => {
const { inventoryService } = await import("@shared/modules/inventory/inventory.service");
const queryParams = parseQuery(url, InventoryRemoveQuerySchema);
if (queryParams instanceof Response) {
return queryParams;
}
const amount = url.searchParams.get("amount");
const quantity = amount ? BigInt(amount) : 1n;
await inventoryService.removeItem(userId, itemId, quantity);
await inventoryService.removeItem(userId, itemId, BigInt(queryParams.amount));
return new Response(null, { status: 204 });
}, "remove item from inventory");
}

View File

@@ -135,8 +135,7 @@ mock.module("@shared/lib/utils", () => ({
// --- Mock Auth (bypass authentication) ---
mock.module("./routes/auth.routes", () => ({
authRoutes: { name: "auth", handler: () => null },
isAuthenticated: () => true,
getSession: () => ({ discordId: "123", username: "testuser", expiresAt: Date.now() + 3600000 }),
getSession: () => ({ discordId: "123", username: "testuser", role: "admin", expiresAt: Date.now() + 3600000 }),
}));
// --- Mock Logger ---

View File

@@ -113,8 +113,7 @@ mock.module("bun", () => {
// Mock auth (bypass authentication)
mock.module("./routes/auth.routes", () => ({
authRoutes: { name: "auth", handler: () => null },
isAuthenticated: () => true,
getSession: () => ({ discordId: "123", username: "testuser", expiresAt: Date.now() + 3600000 }),
getSession: () => ({ discordId: "123", username: "testuser", role: "admin", expiresAt: Date.now() + 3600000 }),
}));
// Import createWebServer after mocks

View File

@@ -1,4 +1,4 @@
import { describe, test, expect, afterAll, mock } from "bun:test";
import { beforeEach, describe, test, expect, afterAll, mock } from "bun:test";
import type { WebServerInstance } from "./server";
interface MockBotStats {
@@ -66,11 +66,17 @@ mock.module("@shared/lib/config", () => ({
}
}));
// 4. Mock auth (bypass authentication for testing)
let currentSession: { discordId: string; username: string; role: "admin" | "player"; expiresAt: number } | null = {
discordId: "123",
username: "admin-user",
role: "admin",
expiresAt: Date.now() + 3600000,
};
// 4. Mock auth with a mutable session so tests can exercise authz paths.
mock.module("./routes/auth.routes", () => ({
authRoutes: { name: "auth", handler: () => null },
isAuthenticated: () => true,
getSession: () => ({ discordId: "123", username: "testuser", expiresAt: Date.now() + 3600000 }),
getSession: () => currentSession,
}));
// 5. Mock BotClient (used by stats helper for maintenanceMode)
@@ -91,37 +97,55 @@ describe("WebServer Security & Limits", () => {
const hostname = "127.0.0.1";
let serverInstance: WebServerInstance | null = null;
beforeEach(() => {
currentSession = {
discordId: "123",
username: "admin-user",
role: "admin",
expiresAt: Date.now() + 3600000,
};
});
afterAll(async () => {
if (serverInstance) {
await serverInstance.stop();
}
});
test("should reject more than 10 concurrent WebSocket connections", async () => {
test("should reject unauthorized websocket requests", async () => {
serverInstance = await createWebServer({ port, hostname });
const wsUrl = `ws://${hostname}:${port}/ws`;
const sockets: WebSocket[] = [];
currentSession = null;
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, 5));
}
const response = await fetch(`http://${hostname}:${port}/ws`);
const body = await response.text();
// Give connections time to settle
await new Promise(resolve => setTimeout(resolve, 800));
expect(response.status).toBe(401);
expect(body).toBe("Unauthorized");
});
const pendingCount = serverInstance.server.pendingWebSockets;
expect(pendingCount).toBeLessThanOrEqual(10);
} finally {
sockets.forEach(s => {
if (s.readyState === WebSocket.OPEN || s.readyState === WebSocket.CONNECTING) {
s.close();
}
});
test("should accept websocket requests for authenticated sessions", async () => {
if (!serverInstance) {
serverInstance = await createWebServer({ port, hostname });
}
const ws = new WebSocket(`ws://${hostname}:${port}/ws`);
const opened = await new Promise<boolean>((resolve) => {
const timeout = setTimeout(() => resolve(false), 1000);
ws.addEventListener("open", () => {
clearTimeout(timeout);
resolve(true);
});
ws.addEventListener("error", () => {
clearTimeout(timeout);
resolve(false);
});
});
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
ws.close();
}
expect(opened).toBe(true);
});
test("should return 200 for health check", async () => {
@@ -135,15 +159,30 @@ describe("WebServer Security & Limits", () => {
});
describe("Administrative Actions", () => {
test("should allow administrative actions without token", async () => {
test("should allow administrative actions for admin sessions", async () => {
const response = await fetch(`http://${hostname}:${port}/api/actions/reload-commands`, {
method: "POST"
});
// Should be 200 (OK) or 500 (if underlying service fails, but NOT 401)
expect(response.status).not.toBe(401);
expect(response.status).toBe(200);
});
test("should reject administrative actions for player sessions", async () => {
currentSession = {
discordId: "456",
username: "player-user",
role: "player",
expiresAt: Date.now() + 3600000,
};
const response = await fetch(`http://${hostname}:${port}/api/actions/reload-commands`, {
method: "POST"
});
expect(response.status).toBe(403);
const data = await response.json() as { error: string };
expect(data.error).toBe("Admin access required");
});
test("should reject maintenance mode with invalid payload", async () => {
const response = await fetch(`http://${hostname}:${port}/api/actions/maintenance-mode`, {
method: "POST",