feat(users): guest role + reserved guest principal, excluded from count/list

This commit is contained in:
saopig1 committed 2026-06-25 11:14:47 +08:00
1 parent fb7f187ede
commit 60c8a5c993
2 files changed
+35 -4

No files matched your search

+26 -1
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createDatabase, type BotDatabase } from "./database.js"; import { createDatabase, type BotDatabase } from "./database.js";
import { createUserStore, UsernameTakenError, type UserStore } from "./users.js"; import { createUserStore, UsernameTakenError, GUEST_USER_ID, GUEST_USERNAME, type UserStore } from "./users.js";
describe("UserStore", () => { describe("UserStore", () => {
let botDb: BotDatabase; let botDb: BotDatabase;
@@ -184,3 +184,28 @@ describe("UserStore", () => {
expect(users.countAdmins()).toBe(1); expect(users.countAdmins()).toBe(1);
}); });
}); });
describe("guest row exclusion", () => {
let botDb: BotDatabase;
let users: UserStore;
beforeEach(() => {
botDb = createDatabase(":memory:");
users = createUserStore(botDb.db);
});
afterEach(() => {
botDb.close();
});
it("countUsers and listUsers ignore the reserved guest row", async () => {
await users.createUser("alice", "password123", "member");
// Insert the reserved guest row directly (mirrors the migration).
botDb.db.prepare(
"INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?, ?, ?, ?, ?, 'guest')"
).run(GUEST_USER_ID, GUEST_USERNAME, "!", Date.now(), Date.now());
expect(users.countUsers()).toBe(1); // alice only
expect(users.listUsers().some((u) => u.id === GUEST_USER_ID)).toBe(false);
});
});
+9 -3
View File
@@ -4,7 +4,13 @@ import bcrypt from "bcryptjs";
const BCRYPT_ROUNDS = 12; const BCRYPT_ROUNDS = 12;
export type UserRole = "admin" | "member"; export type UserRole = "admin" | "member" | "guest";
/** Reserved synthetic principal for login-less guest sessions. The username is
* non-ASCII so it can never collide with an API-created account (which is
* validated against ^[A-Za-z0-9_\-.]{3,32}$). */
export const GUEST_USER_ID = "__guest__";
export const GUEST_USERNAME = "游客";
export interface UserRow { export interface UserRow {
id: string; id: string;
@@ -39,7 +45,7 @@ export class UsernameTakenError extends Error {
} }
export function createUserStore(db: Database.Database): UserStore { export function createUserStore(db: Database.Database): UserStore {
const countStmt = db.prepare("SELECT COUNT(*) AS n FROM users"); const countStmt = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role != 'guest'");
const countAdminsStmt = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'admin'"); const countAdminsStmt = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'admin'");
const insertStmt = db.prepare( const insertStmt = db.prepare(
"INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?, ?, ?, ?, ?, ?)" "INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?, ?, ?, ?, ?, ?)"
@@ -57,7 +63,7 @@ export function createUserStore(db: Database.Database): UserStore {
"UPDATE users SET role = ?, updatedAt = ? WHERE id = ?" "UPDATE users SET role = ?, updatedAt = ? WHERE id = ?"
); );
const listUsersStmt = db.prepare( const listUsersStmt = db.prepare(
"SELECT id, username, createdAt, role FROM users ORDER BY createdAt ASC" "SELECT id, username, createdAt, role FROM users WHERE role != 'guest' ORDER BY createdAt ASC"
); );
const deleteUserStmt = db.prepare("DELETE FROM users WHERE id = ?"); const deleteUserStmt = db.prepare("DELETE FROM users WHERE id = ?");