diff --git a/src/lib/db.ts b/src/lib/db.ts index cac8c3d43..c777fbaf2 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -4,7 +4,13 @@ */ import { type Client, createClient } from "@libsql/client"; -import type { Attendee, Event, EventWithCount, Settings } from "./types.ts"; +import type { + Attendee, + Event, + EventWithCount, + Session, + Settings, +} from "./types.ts"; let db: Client | null = null; @@ -97,6 +103,13 @@ export const initDb = async (): Promise => { } catch { // Column already exists, ignore error } + + await client.execute(` + CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + expires INTEGER NOT NULL + ) + `); }; /** @@ -303,3 +316,48 @@ export const hasAvailableSpots = async (eventId: number): Promise => { if (!event) return false; return event.attendee_count < event.max_attendees; }; + +/** + * Create a new session + */ +export const createSession = async ( + token: string, + expires: number, +): Promise => { + await getDb().execute({ + sql: "INSERT INTO sessions (token, expires) VALUES (?, ?)", + args: [token, expires], + }); +}; + +/** + * Get a session by token + */ +export const getSession = async (token: string): Promise => { + const result = await getDb().execute({ + sql: "SELECT token, expires FROM sessions WHERE token = ?", + args: [token], + }); + if (result.rows.length === 0) return null; + return result.rows[0] as unknown as Session; +}; + +/** + * Delete a session by token + */ +export const deleteSession = async (token: string): Promise => { + await getDb().execute({ + sql: "DELETE FROM sessions WHERE token = ?", + args: [token], + }); +}; + +/** + * Delete all expired sessions + */ +export const deleteExpiredSessions = async (): Promise => { + await getDb().execute({ + sql: "DELETE FROM sessions WHERE expires < ?", + args: [Date.now()], + }); +}; diff --git a/src/lib/types.ts b/src/lib/types.ts index 976f0237e..e4ca34653 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -26,6 +26,11 @@ export interface Settings { value: string; } +export interface Session { + token: string; + expires: number; +} + export interface EventWithCount extends Event { attendee_count: number; } diff --git a/src/server.ts b/src/server.ts index ef62befc1..fc6b87091 100644 --- a/src/server.ts +++ b/src/server.ts @@ -6,12 +6,15 @@ import { isPaymentsEnabled } from "./lib/config.ts"; import { createAttendee, createEvent, + createSession, deleteAttendee, + deleteSession, getAllEvents, getAttendee, getAttendees, getEvent, getEventWithCount, + getSession, hasAvailableSpots, updateAttendeePayment, verifyAdminPassword, @@ -33,8 +36,6 @@ import { } from "./lib/stripe.ts"; import type { Attendee, Event, EventWithCount } from "./lib/types.ts"; -export const sessions = new Map(); - /** * Generate a session token */ @@ -68,16 +69,16 @@ const parseCookies = (request: Request): Map => { /** * Check if request has valid session */ -const isAuthenticated = (request: Request): boolean => { +const isAuthenticated = async (request: Request): Promise => { const cookies = parseCookies(request); const token = cookies.get("session"); if (!token) return false; - const session = sessions.get(token); + const session = await getSession(token); if (!session) return false; if (session.expires < Date.now()) { - sessions.delete(token); + await deleteSession(token); return false; } @@ -116,7 +117,7 @@ const parseFormData = async (request: Request): Promise => { * Handle GET /admin/ */ const handleAdminGet = async (request: Request): Promise => { - if (!isAuthenticated(request)) { + if (!(await isAuthenticated(request))) { return htmlResponse(adminLoginPage()); } const events = await getAllEvents(); @@ -137,7 +138,7 @@ const handleAdminLogin = async (request: Request): Promise => { const token = generateSessionToken(); const expires = Date.now() + 24 * 60 * 60 * 1000; // 24 hours - sessions.set(token, { expires }); + await createSession(token, expires); return redirect( "/admin/", @@ -148,11 +149,11 @@ const handleAdminLogin = async (request: Request): Promise => { /** * Handle GET /admin/logout */ -const handleAdminLogout = (request: Request): Response => { +const handleAdminLogout = async (request: Request): Promise => { const cookies = parseCookies(request); const token = cookies.get("session"); if (token) { - sessions.delete(token); + await deleteSession(token); } return redirect("/admin/", "session=; HttpOnly; Path=/; Max-Age=0"); }; @@ -161,7 +162,7 @@ const handleAdminLogout = (request: Request): Response => { * Handle POST /admin/event (create event) */ const handleCreateEvent = async (request: Request): Promise => { - if (!isAuthenticated(request)) { + if (!(await isAuthenticated(request))) { return redirect("/admin/"); } @@ -187,7 +188,7 @@ const handleAdminEventGet = async ( request: Request, eventId: number, ): Promise => { - if (!isAuthenticated(request)) { + if (!(await isAuthenticated(request))) { return redirect("/admin/"); } diff --git a/test/lib/code-quality.test.ts b/test/lib/code-quality.test.ts new file mode 100644 index 000000000..17750764c --- /dev/null +++ b/test/lib/code-quality.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from "bun:test"; +import { readdir } from "node:fs/promises"; +import { join } from "node:path"; + +const SRC_DIR = join(import.meta.dir, "../../src"); + +/** + * Patterns that indicate in-memory state storage at module level. + * These should be stored in the database instead to survive restarts. + */ +const FORBIDDEN_PATTERNS = [ + { + pattern: /^(?:export\s+)?(?:const|let)\s+\w+\s*=\s*new\s+Map\s*[<(]/m, + description: "Module-level Map (use database instead)", + }, + { + pattern: /^(?:export\s+)?(?:const|let)\s+\w+\s*=\s*new\s+Set\s*[<(]/m, + description: "Module-level Set (use database instead)", + }, + { + pattern: /^(?:export\s+)?(?:const|let)\s+\w+\s*:\s*Map\s* => { + const entries = await readdir(dir, { withFileTypes: true }); + const files: string[] = []; + + for (const entry of entries) { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...(await getAllTsFiles(fullPath))); + } else if (entry.name.endsWith(".ts")) { + files.push(fullPath); + } + } + + return files; +}; + +const getRelativePath = (fullPath: string): string => + fullPath.replace(`${SRC_DIR}/`, ""); + +describe("code quality", () => { + describe("no in-memory state", () => { + test("source files should not use module-level Map or Set for state", async () => { + const files = await getAllTsFiles(SRC_DIR); + const violations: string[] = []; + + for (const file of files) { + const relativePath = getRelativePath(file); + + if (ALLOWED_FILES.includes(relativePath)) { + continue; + } + + const content = await Bun.file(file).text(); + + for (const { pattern, description } of FORBIDDEN_PATTERNS) { + if (pattern.test(content)) { + violations.push(`${relativePath}: ${description}`); + } + } + } + + expect(violations).toEqual([]); + }); + }); +}); diff --git a/test/lib/db.test.ts b/test/lib/db.test.ts index bb6e35dcf..ceaf245da 100644 --- a/test/lib/db.test.ts +++ b/test/lib/db.test.ts @@ -3,7 +3,10 @@ import { createClient } from "@libsql/client"; import { createAttendee, createEvent, + createSession, deleteAttendee, + deleteExpiredSessions, + deleteSession, generatePassword, getAllEvents, getAttendee, @@ -12,6 +15,7 @@ import { getEvent, getEventWithCount, getOrCreateAdminPassword, + getSession, getSetting, hasAvailableSpots, initDb, @@ -452,4 +456,42 @@ describe("db", () => { expect(client1).toBe(client2); }); }); + + describe("sessions", () => { + test("createSession and getSession work together", async () => { + const expires = Date.now() + 1000; + await createSession("test-token", expires); + + const session = await getSession("test-token"); + expect(session).not.toBeNull(); + expect(session?.token).toBe("test-token"); + expect(session?.expires).toBe(expires); + }); + + test("getSession returns null for missing session", async () => { + const session = await getSession("nonexistent"); + expect(session).toBeNull(); + }); + + test("deleteSession removes session", async () => { + await createSession("delete-me", Date.now() + 1000); + await deleteSession("delete-me"); + + const session = await getSession("delete-me"); + expect(session).toBeNull(); + }); + + test("deleteExpiredSessions removes expired sessions", async () => { + await createSession("expired", Date.now() - 1000); + await createSession("valid", Date.now() + 10000); + + await deleteExpiredSessions(); + + const expiredSession = await getSession("expired"); + const validSession = await getSession("valid"); + + expect(expiredSession).toBeNull(); + expect(validSession).not.toBeNull(); + }); + }); }); diff --git a/test/lib/server.test.ts b/test/lib/server.test.ts index ad1171e6e..f04e6c0d7 100644 --- a/test/lib/server.test.ts +++ b/test/lib/server.test.ts @@ -3,12 +3,14 @@ import { createClient } from "@libsql/client"; import { createAttendee, createEvent, + createSession, getOrCreateAdminPassword, + getSession, initDb, setDb, } from "#lib/db.ts"; import { resetStripeClient } from "#lib/stripe.ts"; -import { handleRequest, sessions } from "#src/server.ts"; +import { handleRequest } from "#src/server.ts"; import { mockFormRequest, mockRequest } from "#test-utils"; describe("server", () => { @@ -20,7 +22,6 @@ describe("server", () => { afterEach(() => { setDb(null); - sessions.clear(); }); describe("GET /", () => { @@ -307,8 +308,8 @@ describe("server", () => { }); test("expired session is deleted and shows login page", async () => { - // Add an expired session directly - sessions.set("expired-token", { expires: Date.now() - 1000 }); + // Add an expired session directly to the database + await createSession("expired-token", Date.now() - 1000); const response = await handleRequest( new Request("http://localhost/admin/", { @@ -320,12 +321,13 @@ describe("server", () => { expect(html).toContain("Admin Login"); // Verify the expired session was deleted - expect(sessions.has("expired-token")).toBe(false); + const session = await getSession("expired-token"); + expect(session).toBeNull(); }); }); describe("logout with valid session", () => { - test("deletes session from sessions map", async () => { + test("deletes session from database", async () => { // Log in first const password = await getOrCreateAdminPassword(); const loginResponse = await handleRequest( @@ -335,7 +337,8 @@ describe("server", () => { const token = cookie.split("=")[1]?.split(";")[0] || ""; expect(token).not.toBe(""); - expect(sessions.has(token)).toBe(true); + const sessionBefore = await getSession(token); + expect(sessionBefore).not.toBeNull(); // Now logout const logoutResponse = await handleRequest( @@ -346,7 +349,8 @@ describe("server", () => { expect(logoutResponse.status).toBe(302); // Verify session was deleted - expect(sessions.has(token)).toBe(false); + const sessionAfter = await getSession(token); + expect(sessionAfter).toBeNull(); }); });