mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
The sliding-refresh branch in validateAndTouch hardcoded SESSION_TTL_MS (7d) for all roles, so a guest session created with GUEST_SESSION_TTL_MS (1d) was wrongly bumped to 7d on the first touch after the touch interval. Derive the touch TTL from row.role instead. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
200 lines
9.0 KiB
TypeScript
200 lines
9.0 KiB
TypeScript
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, GUEST_SESSION_TTL_MS } from "./sessions.js";
|
|
|
|
function sha256(token: string) {
|
|
return createHash("sha256").update(token).digest("hex");
|
|
}
|
|
|
|
describe("SessionStore", () => {
|
|
let botDb: BotDatabase;
|
|
let users: UserStore;
|
|
let sessions: SessionStore;
|
|
let userId: string;
|
|
|
|
beforeEach(async () => {
|
|
botDb = createDatabase(":memory:");
|
|
users = createUserStore(botDb.db);
|
|
sessions = createSessionStore(botDb.db);
|
|
const u = await users.createUser("alice", "pw-alice", "admin");
|
|
userId = u.id;
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
botDb.close();
|
|
});
|
|
|
|
it("createSession returns a raw token whose sha256 matches the DB row id", () => {
|
|
const { token } = sessions.createSession(userId);
|
|
const row = botDb.db.prepare("SELECT id FROM sessions").get() as { id: string };
|
|
expect(row.id).toBe(sha256(token));
|
|
expect(row.id).not.toBe(token);
|
|
});
|
|
|
|
it("validateAndTouch returns the user for a fresh token", () => {
|
|
const { token } = sessions.createSession(userId);
|
|
const result = sessions.validateAndTouch(token);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.userId).toBe(userId);
|
|
expect(result!.username).toBe("alice");
|
|
expect(result!.role).toBe("admin");
|
|
});
|
|
|
|
it("validateAndTouch returns null and deletes the row for an expired session", () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
|
const { token } = sessions.createSession(userId);
|
|
vi.setSystemTime(new Date("2026-01-01T00:00:00Z").getTime() + SESSION_TTL_MS + 1000);
|
|
expect(sessions.validateAndTouch(token)).toBeNull();
|
|
const remaining = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n;
|
|
expect(remaining).toBe(0);
|
|
});
|
|
|
|
it("validateAndTouch does not write the DB if called again within the touch interval", () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
|
const { token } = sessions.createSession(userId);
|
|
const before = botDb.db.prepare("SELECT lastSeenAt FROM sessions").get() as { lastSeenAt: number };
|
|
vi.advanceTimersByTime(SESSION_TOUCH_INTERVAL_MS - 1000);
|
|
sessions.validateAndTouch(token);
|
|
const after = botDb.db.prepare("SELECT lastSeenAt FROM sessions").get() as { lastSeenAt: number };
|
|
expect(after.lastSeenAt).toBe(before.lastSeenAt);
|
|
});
|
|
|
|
it("validateAndTouch writes lastSeenAt and extends expiresAt past the touch interval", () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
|
const { token, expiresAt: initialExpiry } = sessions.createSession(userId);
|
|
vi.advanceTimersByTime(SESSION_TOUCH_INTERVAL_MS + 1000);
|
|
sessions.validateAndTouch(token);
|
|
const row = botDb.db.prepare("SELECT lastSeenAt, expiresAt FROM sessions").get() as { lastSeenAt: number; expiresAt: number };
|
|
expect(row.lastSeenAt).toBe(Date.now());
|
|
expect(row.expiresAt).toBeGreaterThan(initialExpiry);
|
|
});
|
|
|
|
it("deleteSession removes the row", () => {
|
|
const { token } = sessions.createSession(userId);
|
|
sessions.deleteSession(token);
|
|
const remaining = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n;
|
|
expect(remaining).toBe(0);
|
|
expect(sessions.validateAndTouch(token)).toBeNull();
|
|
});
|
|
|
|
it("deleteAllForUser keeps the exceptToken session", () => {
|
|
const a = sessions.createSession(userId);
|
|
const b = sessions.createSession(userId);
|
|
sessions.deleteAllForUser(userId, a.token);
|
|
expect(sessions.validateAndTouch(a.token)).not.toBeNull();
|
|
expect(sessions.validateAndTouch(b.token)).toBeNull();
|
|
});
|
|
|
|
it("cleanupExpired removes only expired rows", () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
|
sessions.createSession(userId); // expires later
|
|
vi.setSystemTime(new Date("2026-01-01T00:00:00Z").getTime() + SESSION_TTL_MS + 1000);
|
|
sessions.createSession(userId); // fresh
|
|
sessions.cleanupExpired();
|
|
const remaining = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n;
|
|
expect(remaining).toBe(1);
|
|
});
|
|
|
|
it("createSession caps concurrent sessions per user at MAX_SESSIONS_PER_USER, evicting oldest", async () => {
|
|
// Create MAX + 2 sessions for the same user.
|
|
const tokens: string[] = [];
|
|
for (let i = 0; i < MAX_SESSIONS_PER_USER + 2; i++) {
|
|
tokens.push(sessions.createSession(userId).token);
|
|
await new Promise((r) => setTimeout(r, 2)); // stagger createdAt
|
|
}
|
|
const count = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n;
|
|
expect(count).toBe(MAX_SESSIONS_PER_USER);
|
|
// The first two should have been evicted, the last MAX remain
|
|
expect(sessions.validateAndTouch(tokens[0])).toBeNull();
|
|
expect(sessions.validateAndTouch(tokens[1])).toBeNull();
|
|
expect(sessions.validateAndTouch(tokens[tokens.length - 1])).not.toBeNull();
|
|
});
|
|
|
|
it("createSession respects cap under concurrent calls (no 1-over-cap race)", async () => {
|
|
// better-sqlite3 transactions are serialised at the engine level. Calling
|
|
// createSession N times sequentially via Promise.all proves atomic check+insert.
|
|
const N = MAX_SESSIONS_PER_USER + 3;
|
|
await Promise.all(Array.from({ length: N }, () => Promise.resolve(sessions.createSession(userId))));
|
|
const count = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n;
|
|
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 OR IGNORE 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);
|
|
});
|
|
|
|
it("validateAndTouch refreshes a guest session to GUEST_SESSION_TTL_MS (1d), not SESSION_TTL_MS (7d)", () => {
|
|
const { token } = sessions.createSession("__guest__", { ttlMs: GUEST_SESSION_TTL_MS, skipCap: true });
|
|
// Force the touch branch: backdate lastSeenAt past the touch interval.
|
|
botDb.db
|
|
.prepare("UPDATE sessions SET lastSeenAt = ? WHERE userId = '__guest__'")
|
|
.run(Date.now() - (SESSION_TOUCH_INTERVAL_MS + 1000));
|
|
const result = sessions.validateAndTouch(token);
|
|
expect(result?.role).toBe("guest");
|
|
const row = botDb.db
|
|
.prepare("SELECT expiresAt FROM sessions WHERE userId = '__guest__'")
|
|
.get() as { expiresAt: number };
|
|
// Should refresh to ~now + 1 day, NOT now + 7 days.
|
|
expect(row.expiresAt).toBeGreaterThan(Date.now() + GUEST_SESSION_TTL_MS - 5000);
|
|
expect(row.expiresAt).toBeLessThanOrEqual(Date.now() + GUEST_SESSION_TTL_MS + 5000);
|
|
// Sanity: well below the 7d window.
|
|
expect(row.expiresAt).toBeLessThan(Date.now() + SESSION_TTL_MS);
|
|
});
|
|
|
|
it("validateAndTouch still refreshes a non-guest (admin) session to SESSION_TTL_MS (7d) on touch", () => {
|
|
botDb.db
|
|
.prepare("INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES ('admin1','adminuser','!',?,?, 'admin')")
|
|
.run(Date.now(), Date.now());
|
|
const { token } = sessions.createSession("admin1");
|
|
botDb.db
|
|
.prepare("UPDATE sessions SET lastSeenAt = ? WHERE userId = 'admin1'")
|
|
.run(Date.now() - (SESSION_TOUCH_INTERVAL_MS + 1000));
|
|
const result = sessions.validateAndTouch(token);
|
|
expect(result?.role).toBe("admin");
|
|
const row = botDb.db
|
|
.prepare("SELECT expiresAt FROM sessions WHERE userId = 'admin1'")
|
|
.get() as { expiresAt: number };
|
|
// Refreshes to ~now + 7 days, NOT the 1d guest window.
|
|
expect(row.expiresAt).toBeGreaterThan(Date.now() + SESSION_TTL_MS - 5000);
|
|
expect(row.expiresAt).toBeLessThanOrEqual(Date.now() + SESSION_TTL_MS + 5000);
|
|
});
|
|
});
|