diff --git a/src/data/database.test.ts b/src/data/database.test.ts index 52569ff..84788e3 100644 --- a/src/data/database.test.ts +++ b/src/data/database.test.ts @@ -1,5 +1,9 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { createDatabase, type BotDatabase, type BotInstance, type PlayHistoryEntry } from "./database.js"; +import { createUserStore, GUEST_USER_ID } from "./users.js"; describe("database", () => { let botDb: BotDatabase; @@ -148,3 +152,28 @@ describe("database", () => { expect(botDb.getCustomAvatarPath("bot-1")).toBeNull(); }); }); + +describe("guest principal migration", () => { + it("creates exactly one reserved guest row, idempotently", () => { + const dir = mkdtempSync(join(tmpdir(), "tsmb-db-")); + const p = join(dir, "t.db"); + const a = createDatabase(p); a.db.close(); + const b = createDatabase(p); // run again — must not duplicate + const row = b.db.prepare("SELECT id, role FROM users WHERE id = ?").get(GUEST_USER_ID) as { id: string; role: string } | undefined; + expect(row?.role).toBe("guest"); + const n = (b.db.prepare("SELECT COUNT(*) AS n FROM users WHERE role='guest'").get() as { n: number }).n; + expect(n).toBe(1); + b.db.close(); + rmSync(dir, { recursive: true, force: true }); + }); + + it("guest row does not break first-run detection (countUsers excludes it)", () => { + const dir = mkdtempSync(join(tmpdir(), "tsmb-db2-")); + const p = join(dir, "t.db"); + const d = createDatabase(p); + const users = createUserStore(d.db); + expect(users.countUsers()).toBe(0); // guest excluded → still needs setup + d.db.close(); + rmSync(dir, { recursive: true, force: true }); + }); +}); diff --git a/src/data/database.ts b/src/data/database.ts index ef6b171..cc7f3d8 100644 --- a/src/data/database.ts +++ b/src/data/database.ts @@ -1,5 +1,6 @@ import Database from "better-sqlite3"; import { CAPABILITIES, BOTS_ALL } from "./permissions.js"; +import { GUEST_USER_ID, GUEST_USERNAME } from "./users.js"; export interface PlayHistoryEntry { botId: string; @@ -241,6 +242,19 @@ export function backfillMemberPermissions(db: Database.Database): void { tx(); } +/** + * Ensure the reserved guest principal exists. Idempotent via the PK on + * `users.id`. This row only backs login-less guest sessions; it is excluded + * from countUsers()/listUsers() so it never interferes with first-run setup + * or the user-management UI, and holds an unusable password hash. + */ +export function ensureGuestUser(db: Database.Database): void { + const now = Date.now(); + db.prepare( + "INSERT OR IGNORE INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?, ?, '!', ?, ?, 'guest')" + ).run(GUEST_USER_ID, GUEST_USERNAME, now, now); +} + export function createDatabase(dbPath: string): BotDatabase { const db = new Database(dbPath); db.pragma("journal_mode = WAL"); @@ -248,6 +262,7 @@ export function createDatabase(dbPath: string): BotDatabase { initTables(db); migrateSchema(db); backfillMemberPermissions(db); + ensureGuestUser(db); const insertHistory = db.prepare(` INSERT INTO play_history (botId, songId, songName, artist, album, platform, coverUrl) diff --git a/src/data/sessions.test.ts b/src/data/sessions.test.ts index c711e8e..ff13548 100644 --- a/src/data/sessions.test.ts +++ b/src/data/sessions.test.ts @@ -136,7 +136,7 @@ describe("guest sessions", () => { 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')") + .prepare("INSERT OR IGNORE INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES ('__guest__','游客','!',?,?, 'guest')") .run(Date.now(), Date.now()); }); diff --git a/src/data/users.test.ts b/src/data/users.test.ts index 992622a..dc6294b 100644 --- a/src/data/users.test.ts +++ b/src/data/users.test.ts @@ -202,7 +202,7 @@ describe("guest row exclusion", () => { 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')" + "INSERT OR IGNORE 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