diff --git a/src/data/sessions.test.ts b/src/data/sessions.test.ts index b7a8dcf..c711e8e 100644 --- a/src/data/sessions.test.ts +++ b/src/data/sessions.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { createHash } from "node:crypto"; import { createDatabase, type BotDatabase } from "./database.js"; import { createUserStore, type UserStore } from "./users.js"; -import { createSessionStore, type SessionStore, SESSION_TTL_MS, SESSION_TOUCH_INTERVAL_MS, MAX_SESSIONS_PER_USER } from "./sessions.js"; +import { createSessionStore, type SessionStore, SESSION_TTL_MS, SESSION_TOUCH_INTERVAL_MS, MAX_SESSIONS_PER_USER, GUEST_SESSION_TTL_MS } from "./sessions.js"; function sha256(token: string) { return createHash("sha256").update(token).digest("hex"); @@ -126,3 +126,38 @@ describe("SessionStore", () => { expect(count).toBe(MAX_SESSIONS_PER_USER); }); }); + +describe("guest sessions", () => { + let botDb: BotDatabase; + let sessions: SessionStore; + + beforeEach(() => { + botDb = createDatabase(":memory:"); + sessions = createSessionStore(botDb.db); + // Create the synthetic guest user row to satisfy the sessions FK. + botDb.db + .prepare("INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES ('__guest__','游客','!',?,?, 'guest')") + .run(Date.now(), Date.now()); + }); + + afterEach(() => { + vi.useRealTimers(); + botDb.close(); + }); + + it("skipCap lets more than MAX_SESSIONS_PER_USER coexist for one principal", () => { + const tokens: string[] = []; + for (let i = 0; i < MAX_SESSIONS_PER_USER + 3; i++) { + tokens.push(sessions.createSession("__guest__", { ttlMs: GUEST_SESSION_TTL_MS, skipCap: true }).token); + } + // The first token must STILL validate (not evicted). + expect(sessions.validateAndTouch(tokens[0])?.role).toBe("guest"); + const n = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions WHERE userId='__guest__'").get() as { n: number }).n; + expect(n).toBe(MAX_SESSIONS_PER_USER + 3); + }); + + it("ttlMs sets a shorter expiry than the default", () => { + const { expiresAt } = sessions.createSession("__guest__", { ttlMs: GUEST_SESSION_TTL_MS, skipCap: true }); + expect(expiresAt).toBeLessThanOrEqual(Date.now() + GUEST_SESSION_TTL_MS + 50); + }); +}); diff --git a/src/data/sessions.ts b/src/data/sessions.ts index bf446d2..6da5930 100644 --- a/src/data/sessions.ts +++ b/src/data/sessions.ts @@ -2,17 +2,18 @@ import { createHash, randomBytes } from "node:crypto"; import type Database from "better-sqlite3"; export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days +export const GUEST_SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 1 day — guests are short-lived export const SESSION_TOUCH_INTERVAL_MS = 60 * 60 * 1000; // 1 hour export const MAX_SESSIONS_PER_USER = 10; export interface SessionValidation { userId: string; username: string; - role: "admin" | "member"; + role: "admin" | "member" | "guest"; } export interface SessionStore { - createSession(userId: string): { token: string; expiresAt: number }; + createSession(userId: string, opts?: { ttlMs?: number; skipCap?: boolean }): { token: string; expiresAt: number }; validateAndTouch(rawToken: string): SessionValidation | null; deleteSession(rawToken: string): void; deleteAllForUser(userId: string, exceptToken?: string): void; @@ -47,7 +48,7 @@ export function createSessionStore(db: Database.Database): SessionStore { ); return { - createSession(userId) { + createSession(userId, opts) { // Cap concurrent sessions per user — oldest gets evicted on overflow. // Wrap the count → delete → insert in a transaction so concurrent logins // for the same user can't both pass the cap check and both insert, @@ -55,11 +56,13 @@ export function createSessionStore(db: Database.Database): SessionStore { const token = randomBytes(32).toString("base64url"); const id = hashToken(token); const now = Date.now(); - const expiresAt = now + SESSION_TTL_MS; + const expiresAt = now + (opts?.ttlMs ?? SESSION_TTL_MS); const tx = db.transaction(() => { - const existing = (countForUserStmt.get(userId) as { n: number }).n; - if (existing >= MAX_SESSIONS_PER_USER) { - deleteOldestForUserStmt.run(userId, existing - MAX_SESSIONS_PER_USER + 1); + if (!opts?.skipCap) { + const existing = (countForUserStmt.get(userId) as { n: number }).n; + if (existing >= MAX_SESSIONS_PER_USER) { + deleteOldestForUserStmt.run(userId, existing - MAX_SESSIONS_PER_USER + 1); + } } insertStmt.run(id, userId, now, expiresAt, now); }); @@ -82,7 +85,7 @@ export function createSessionStore(db: Database.Database): SessionStore { if (now - row.lastSeenAt > SESSION_TOUCH_INTERVAL_MS) { touchStmt.run(now, now + SESSION_TTL_MS, id); } - return { userId: row.userId, username: row.username, role: row.role as "admin" | "member" }; + return { userId: row.userId, username: row.username, role: row.role as "admin" | "member" | "guest" }; }, deleteSession(rawToken) {