mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +08:00
feat(auth): user-management audit log (table + record sites + /api/audit endpoint)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
ceb24595e6
commit
6af0e97f51
11 files changed
+256
-8
No files matched your search
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { createDatabase, type BotDatabase } from "./database.js";
|
||||
import { createAuditStore, type AuditStore } from "./audit.js";
|
||||
|
||||
describe("AuditStore", () => {
|
||||
let botDb: BotDatabase;
|
||||
let audit: AuditStore;
|
||||
|
||||
beforeEach(() => {
|
||||
botDb = createDatabase(":memory:");
|
||||
audit = createAuditStore(botDb.db);
|
||||
});
|
||||
|
||||
afterEach(() => botDb.close());
|
||||
|
||||
it("records and lists entries newest-first", async () => {
|
||||
audit.record({
|
||||
actorId: "a1", actorUsername: "alice",
|
||||
targetUserId: "b1", targetUsername: "bob",
|
||||
action: "user.created",
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
audit.record({
|
||||
actorId: "a1", actorUsername: "alice",
|
||||
targetUserId: "b1", targetUsername: "bob",
|
||||
action: "user.deleted",
|
||||
});
|
||||
const list = audit.list(10, 0);
|
||||
expect(list).toHaveLength(2);
|
||||
expect(list[0].action).toBe("user.deleted");
|
||||
expect(list[1].action).toBe("user.created");
|
||||
});
|
||||
|
||||
it("supports limit and offset", () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
audit.record({
|
||||
actorId: "a1", actorUsername: "alice",
|
||||
targetUserId: null, targetUsername: null,
|
||||
action: "user.password_changed",
|
||||
});
|
||||
}
|
||||
expect(audit.list(2, 0)).toHaveLength(2);
|
||||
expect(audit.list(2, 4)).toHaveLength(1);
|
||||
expect(audit.list(10, 10)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("stores nullable fields correctly", () => {
|
||||
audit.record({
|
||||
actorId: null, actorUsername: null,
|
||||
targetUserId: "x", targetUsername: "deleted-user",
|
||||
action: "admin.first_created",
|
||||
});
|
||||
const e = audit.list(1, 0)[0];
|
||||
expect(e.actorId).toBeNull();
|
||||
expect(e.actorUsername).toBeNull();
|
||||
expect(e.targetUserId).toBe("x");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import type Database from "better-sqlite3";
|
||||
|
||||
export type AuditAction =
|
||||
| "admin.first_created"
|
||||
| "user.created"
|
||||
| "user.deleted"
|
||||
| "user.password_reset"
|
||||
| "user.password_changed";
|
||||
|
||||
export interface AuditEntry {
|
||||
id: number;
|
||||
timestamp: number;
|
||||
actorId: string | null;
|
||||
actorUsername: string | null;
|
||||
targetUserId: string | null;
|
||||
targetUsername: string | null;
|
||||
action: AuditAction;
|
||||
}
|
||||
|
||||
export interface AuditRecordInput {
|
||||
actorId: string | null;
|
||||
actorUsername: string | null;
|
||||
targetUserId: string | null;
|
||||
targetUsername: string | null;
|
||||
action: AuditAction;
|
||||
}
|
||||
|
||||
export interface AuditStore {
|
||||
record(input: AuditRecordInput): void;
|
||||
list(limit: number, offset: number): AuditEntry[];
|
||||
}
|
||||
|
||||
export function createAuditStore(db: Database.Database): AuditStore {
|
||||
const insertStmt = db.prepare(
|
||||
"INSERT INTO user_audit (timestamp, actorId, actorUsername, targetUserId, targetUsername, action) VALUES (?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
const listStmt = db.prepare(
|
||||
"SELECT id, timestamp, actorId, actorUsername, targetUserId, targetUsername, action FROM user_audit ORDER BY timestamp DESC, id DESC LIMIT ? OFFSET ?"
|
||||
);
|
||||
|
||||
return {
|
||||
record(input) {
|
||||
insertStmt.run(
|
||||
Date.now(),
|
||||
input.actorId,
|
||||
input.actorUsername,
|
||||
input.targetUserId,
|
||||
input.targetUsername,
|
||||
input.action
|
||||
);
|
||||
},
|
||||
list(limit, offset) {
|
||||
return listStmt.all(limit, offset) as AuditEntry[];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -40,6 +40,13 @@ describe("database", () => {
|
||||
expect(sessionColNames).toEqual(["createdAt", "expiresAt", "id", "lastSeenAt", "userId"]);
|
||||
});
|
||||
|
||||
it("creates user_audit table on init", () => {
|
||||
const tables = botDb.db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
||||
.all() as Array<{ name: string }>;
|
||||
expect(tables.map((t) => t.name)).toContain("user_audit");
|
||||
});
|
||||
|
||||
it("records and retrieves play history", () => {
|
||||
botDb.addPlayHistory({
|
||||
botId: "bot1",
|
||||
|
||||
@@ -147,6 +147,17 @@ function initTables(db: Database.Database): void {
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_userId ON sessions(userId);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expiresAt ON sessions(expiresAt);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_audit (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp INTEGER NOT NULL,
|
||||
actorId TEXT,
|
||||
actorUsername TEXT,
|
||||
targetUserId TEXT,
|
||||
targetUsername TEXT,
|
||||
action TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_audit_timestamp ON user_audit(timestamp DESC);
|
||||
`);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user