mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
The by-id user-management handlers use findById, which has no role filter, so supplying the synthetic GUEST_USER_ID let an admin delete, re-role, reset-password, and read/write permissions on the shared guest principal (privilege-escalation / DoS / credential-login holes). - web/api/users.ts: 404-guard every :id handler against GUEST_USER_ID (DELETE, reset-password, role, GET/PUT permissions). - data/users.ts: defense-in-depth — setRoleIfNotLastAdmin and deleteUserIfNotLastAdmin return "not_found" for any role=guest row. - web/api/session.ts: wrap POST /guest createSession in try/catch so a missing guest row yields 503 instead of an unhandled 500. - Tests: data-layer guest-protection + users-router 404 by-id guards. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
391 lines
15 KiB
TypeScript
391 lines
15 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
import express from "express";
|
|
import cookieParser from "cookie-parser";
|
|
import request from "supertest";
|
|
import pino from "pino";
|
|
import { createDatabase, type BotDatabase } from "../../data/database.js";
|
|
import { createUserStore, GUEST_USER_ID, type UserStore } from "../../data/users.js";
|
|
import { createSessionStore, type SessionStore } from "../../data/sessions.js";
|
|
import { createAuditStore, type AuditStore } from "../../data/audit.js";
|
|
import { createPermissionStore, type PermissionStore } from "../../data/permissions.js";
|
|
import { getDefaultConfig } from "../../data/config.js";
|
|
import { createRequireAuth } from "../middleware/requireAuth.js";
|
|
import { createUsersRouter } from "./users.js";
|
|
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
|
|
|
|
function makeApp(botDb: BotDatabase, users: UserStore, sessions: SessionStore) {
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use(cookieParser());
|
|
const permissions = createPermissionStore(botDb.db);
|
|
const requireAuth = createRequireAuth(sessions, permissions, () => getDefaultConfig().guestMode);
|
|
const audit = createAuditStore(botDb.db);
|
|
app.use("/api", requireAuth);
|
|
app.use("/api/users", createUsersRouter(users, sessions, audit, pino({ level: "silent" }), permissions));
|
|
return { app, permissions, audit };
|
|
}
|
|
|
|
describe("users router", () => {
|
|
let botDb: BotDatabase;
|
|
let users: UserStore;
|
|
let sessions: SessionStore;
|
|
let app: express.Express;
|
|
let permissions: PermissionStore;
|
|
let audit: AuditStore;
|
|
let aliceId: string;
|
|
let aliceCookie: string;
|
|
let bobId: string;
|
|
|
|
beforeEach(async () => {
|
|
botDb = createDatabase(":memory:");
|
|
users = createUserStore(botDb.db);
|
|
sessions = createSessionStore(botDb.db);
|
|
({ app, permissions, audit } = makeApp(botDb, users, sessions));
|
|
const alice = await users.createUser("alice", "pw-alice", "admin");
|
|
aliceId = alice.id;
|
|
aliceCookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(alice.id).token}`;
|
|
const bob = await users.createUser("bob", "pw-bob-bob", "member");
|
|
bobId = bob.id;
|
|
});
|
|
|
|
afterEach(() => botDb.close());
|
|
|
|
it("requires auth for all routes", async () => {
|
|
expect((await request(app).get("/api/users")).status).toBe(401);
|
|
expect((await request(app).post("/api/users").send({ username: "x", password: "yyyyyyyy" })).status).toBe(401);
|
|
expect((await request(app).delete(`/api/users/${bobId}`)).status).toBe(401);
|
|
});
|
|
|
|
it("GET / lists users with id+username+createdAt, no password hash", async () => {
|
|
const res = await request(app).get("/api/users").set("Cookie", aliceCookie);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.users).toHaveLength(2);
|
|
for (const u of res.body.users) {
|
|
expect(u).toHaveProperty("id");
|
|
expect(u).toHaveProperty("username");
|
|
expect(u).toHaveProperty("createdAt");
|
|
expect(u).not.toHaveProperty("passwordHash");
|
|
}
|
|
});
|
|
|
|
it("POST / creates a user", async () => {
|
|
const res = await request(app)
|
|
.post("/api/users")
|
|
.set("Cookie", aliceCookie)
|
|
.send({ username: "charlie", password: "charlie-pw" });
|
|
expect(res.status).toBe(201);
|
|
expect(res.body.username).toBe("charlie");
|
|
expect(users.countUsers()).toBe(3);
|
|
});
|
|
|
|
it("POST / returns 409 on duplicate username", async () => {
|
|
const res = await request(app)
|
|
.post("/api/users")
|
|
.set("Cookie", aliceCookie)
|
|
.send({ username: "BOB", password: "another-pw" });
|
|
expect(res.status).toBe(409);
|
|
});
|
|
|
|
it("POST / returns 400 on invalid input", async () => {
|
|
const res = await request(app)
|
|
.post("/api/users")
|
|
.set("Cookie", aliceCookie)
|
|
.send({ username: "x", password: "short" });
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it("DELETE /:id removes the user and their sessions", async () => {
|
|
const bobToken = sessions.createSession(bobId).token;
|
|
const res = await request(app).delete(`/api/users/${bobId}`).set("Cookie", aliceCookie);
|
|
expect(res.status).toBe(204);
|
|
expect(users.countUsers()).toBe(1);
|
|
expect(sessions.validateAndTouch(bobToken)).toBeNull();
|
|
});
|
|
|
|
it("DELETE /:id of self returns 400", async () => {
|
|
const res = await request(app).delete(`/api/users/${aliceId}`).set("Cookie", aliceCookie);
|
|
expect(res.status).toBe(400);
|
|
expect(res.body).toEqual({ error: "cannot delete self" });
|
|
expect(users.countUsers()).toBe(2);
|
|
});
|
|
|
|
it("DELETE /:id of nonexistent returns 404", async () => {
|
|
const res = await request(app).delete(`/api/users/not-a-real-id`).set("Cookie", aliceCookie);
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it("POST /:id/reset-password updates the hash and invalidates target's sessions", async () => {
|
|
const bobToken = sessions.createSession(bobId).token;
|
|
const res = await request(app)
|
|
.post(`/api/users/${bobId}/reset-password`)
|
|
.set("Cookie", aliceCookie)
|
|
.send({ newPassword: "bob-new-pw" });
|
|
expect(res.status).toBe(204);
|
|
expect(sessions.validateAndTouch(bobToken)).toBeNull();
|
|
const bob = users.findByUsername("bob");
|
|
expect(await users.verifyPassword("bob-new-pw", bob!.passwordHash)).toBe(true);
|
|
expect(await users.verifyPassword("pw-bob-bob", bob!.passwordHash)).toBe(false);
|
|
});
|
|
|
|
it("POST /:id/reset-password 404 on unknown user", async () => {
|
|
const res = await request(app)
|
|
.post(`/api/users/not-a-real-id/reset-password`)
|
|
.set("Cookie", aliceCookie)
|
|
.send({ newPassword: "anything-here" });
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it("POST /:id/reset-password 400 on short password", async () => {
|
|
const res = await request(app)
|
|
.post(`/api/users/${bobId}/reset-password`)
|
|
.set("Cookie", aliceCookie)
|
|
.send({ newPassword: "short" });
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it("returns 201 even if audit insert fails (POST /api/users)", async () => {
|
|
// Build a broken audit store that throws on record()
|
|
const brokenAudit = {
|
|
record: () => { throw new Error("simulated disk-full"); },
|
|
list: () => [],
|
|
};
|
|
// Reassemble app with the broken audit
|
|
const localApp = express();
|
|
localApp.use(express.json());
|
|
localApp.use(cookieParser());
|
|
localApp.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db), () => getDefaultConfig().guestMode));
|
|
localApp.use(
|
|
"/api/users",
|
|
createUsersRouter(users, sessions, brokenAudit, pino({ level: "silent" }), createPermissionStore(botDb.db))
|
|
);
|
|
const res = await request(localApp)
|
|
.post("/api/users")
|
|
.set("Cookie", aliceCookie)
|
|
.send({ username: "charlie", password: "charlie-pw" });
|
|
expect(res.status).toBe(201);
|
|
expect(users.countUsers()).toBe(3);
|
|
});
|
|
|
|
it("POST /:id/reset-password on self preserves the actor's current session", async () => {
|
|
// Alice resets her OWN password
|
|
const res = await request(app)
|
|
.post(`/api/users/${aliceId}/reset-password`)
|
|
.set("Cookie", aliceCookie)
|
|
.send({ newPassword: "alice-new-pw" });
|
|
expect(res.status).toBe(204);
|
|
|
|
// Alice's CURRENT session should still work
|
|
// (we'd need a protected endpoint to verify; use GET /api/users which is already mounted)
|
|
const followUp = await request(app).get("/api/users").set("Cookie", aliceCookie);
|
|
expect(followUp.status).toBe(200);
|
|
|
|
// The password hash IS updated (sanity check)
|
|
const alice = users.findById(aliceId);
|
|
expect(await users.verifyPassword("alice-new-pw", alice!.passwordHash)).toBe(true);
|
|
});
|
|
|
|
it("POST /:id/reset-password on another user does NOT preserve any of target's sessions", async () => {
|
|
const bobToken = sessions.createSession(bobId).token;
|
|
const res = await request(app)
|
|
.post(`/api/users/${bobId}/reset-password`)
|
|
.set("Cookie", aliceCookie)
|
|
.send({ newPassword: "bob-new-pw" });
|
|
expect(res.status).toBe(204);
|
|
// Bob's session should be dead
|
|
expect(sessions.validateAndTouch(bobToken)).toBeNull();
|
|
});
|
|
|
|
it("POST / defaults new user to role=member when role omitted", async () => {
|
|
const res = await request(app)
|
|
.post("/api/users")
|
|
.set("Cookie", aliceCookie)
|
|
.send({ username: "carol", password: "pw-carol-pw" });
|
|
expect(res.status).toBe(201);
|
|
expect(res.body.role).toBe("member");
|
|
});
|
|
|
|
it("POST / accepts role=admin", async () => {
|
|
const res = await request(app)
|
|
.post("/api/users")
|
|
.set("Cookie", aliceCookie)
|
|
.send({ username: "carol", password: "pw-carol-pw", role: "admin" });
|
|
expect(res.status).toBe(201);
|
|
expect(res.body.role).toBe("admin");
|
|
expect(users.countAdmins()).toBe(2);
|
|
});
|
|
|
|
it("PATCH /:id/role can change role between admin and member", async () => {
|
|
const res = await request(app)
|
|
.patch(`/api/users/${bobId}/role`)
|
|
.set("Cookie", aliceCookie)
|
|
.send({ role: "admin" });
|
|
expect(res.status).toBe(204);
|
|
expect(users.findById(bobId)!.role).toBe("admin");
|
|
});
|
|
|
|
it("PATCH /:id/role blocks demoting the last admin", async () => {
|
|
// alice is the only admin. Demoting her would leave 0 admins. Block.
|
|
const res = await request(app)
|
|
.patch(`/api/users/${aliceId}/role`)
|
|
.set("Cookie", aliceCookie)
|
|
.send({ role: "member" });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body).toEqual({ error: "cannot demote last admin" });
|
|
});
|
|
|
|
it("PATCH /:id/role allows demoting an admin when other admins exist", async () => {
|
|
// Promote bob first
|
|
users.setRole(bobId, "admin");
|
|
// Now both are admins. Demoting alice should work.
|
|
const res = await request(app)
|
|
.patch(`/api/users/${aliceId}/role`)
|
|
.set("Cookie", aliceCookie)
|
|
.send({ role: "member" });
|
|
expect(res.status).toBe(204);
|
|
});
|
|
|
|
it("PATCH /:id/role 400 on invalid role", async () => {
|
|
const res = await request(app)
|
|
.patch(`/api/users/${bobId}/role`)
|
|
.set("Cookie", aliceCookie)
|
|
.send({ role: "superuser" });
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it("PATCH /:id/role 404 on unknown user", async () => {
|
|
const res = await request(app)
|
|
.patch(`/api/users/not-a-real-id/role`)
|
|
.set("Cookie", aliceCookie)
|
|
.send({ role: "admin" });
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it("GET /:id/permissions returns empty arrays for a fresh member", async () => {
|
|
const res = await request(app)
|
|
.get(`/api/users/${bobId}/permissions`)
|
|
.set("Cookie", aliceCookie);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ capabilities: [], bots: [] });
|
|
});
|
|
|
|
it("GET /:id/permissions 404 on unknown user", async () => {
|
|
const res = await request(app)
|
|
.get(`/api/users/not-a-real-id/permissions`)
|
|
.set("Cookie", aliceCookie);
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it("PUT /:id/permissions sets permissions, persists, and audits", async () => {
|
|
const before = audit.list(100, 0).filter((e) => e.action === "user.permissions_changed");
|
|
expect(before).toHaveLength(0);
|
|
|
|
const put = await request(app)
|
|
.put(`/api/users/${bobId}/permissions`)
|
|
.set("Cookie", aliceCookie)
|
|
.send({ capabilities: ["player.control"], bots: "all" });
|
|
expect(put.status).toBe(200);
|
|
|
|
const get = await request(app)
|
|
.get(`/api/users/${bobId}/permissions`)
|
|
.set("Cookie", aliceCookie);
|
|
expect(get.status).toBe(200);
|
|
expect(get.body).toEqual({ capabilities: ["player.control"], bots: "all" });
|
|
|
|
const rows = audit.list(100, 0).filter((e) => e.action === "user.permissions_changed");
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0].actorId).toBe(aliceId);
|
|
expect(rows[0].targetUserId).toBe(bobId);
|
|
});
|
|
|
|
it("PUT /:id/permissions drops unknown capability tokens", async () => {
|
|
const put = await request(app)
|
|
.put(`/api/users/${bobId}/permissions`)
|
|
.set("Cookie", aliceCookie)
|
|
.send({ capabilities: ["player.control", "bogus"], bots: [] });
|
|
expect(put.status).toBe(200);
|
|
expect(permissions.getCapabilities(bobId)).toEqual(["player.control"]);
|
|
});
|
|
|
|
it("PUT /:id/permissions 404 on unknown user", async () => {
|
|
const res = await request(app)
|
|
.put(`/api/users/not-a-real-id/permissions`)
|
|
.set("Cookie", aliceCookie)
|
|
.send({ capabilities: ["player.control"], bots: "all" });
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it("POST / seeds the basic tier for a new member", async () => {
|
|
const res = await request(app)
|
|
.post("/api/users")
|
|
.set("Cookie", aliceCookie)
|
|
.send({ username: "dave", password: "dave-pw-pw" });
|
|
expect(res.status).toBe(201);
|
|
const perms = await request(app)
|
|
.get(`/api/users/${res.body.id}/permissions`)
|
|
.set("Cookie", aliceCookie);
|
|
expect(perms.status).toBe(200);
|
|
expect(perms.body).toEqual({
|
|
capabilities: ["player.control", "player.queue"],
|
|
bots: "all",
|
|
});
|
|
});
|
|
|
|
it("POST / does NOT seed permissions for a new admin", async () => {
|
|
const res = await request(app)
|
|
.post("/api/users")
|
|
.set("Cookie", aliceCookie)
|
|
.send({ username: "erin", password: "erin-pw-pw", role: "admin" });
|
|
expect(res.status).toBe(201);
|
|
const perms = await request(app)
|
|
.get(`/api/users/${res.body.id}/permissions`)
|
|
.set("Cookie", aliceCookie);
|
|
expect(perms.status).toBe(200);
|
|
expect(perms.body).toEqual({ capabilities: [], bots: [] });
|
|
});
|
|
|
|
// --- reserved guest principal is never mutable/visible via user mgmt -------
|
|
// The synthetic __guest__ row is seeded by createDatabase. findById has no
|
|
// role filter, so without the 404-guard these by-id handlers would operate
|
|
// on it (privilege-escalation / DoS / cred-login holes).
|
|
describe("reserved __guest__ principal is 404 on every by-id handler", () => {
|
|
it("DELETE /:id → 404 and the guest row survives", async () => {
|
|
const res = await request(app).delete(`/api/users/${GUEST_USER_ID}`).set("Cookie", aliceCookie);
|
|
expect(res.status).toBe(404);
|
|
expect(users.findById(GUEST_USER_ID)).not.toBeNull();
|
|
expect(users.findById(GUEST_USER_ID)!.role).toBe("guest");
|
|
});
|
|
|
|
it("PATCH /:id/role {role:'admin'} → 404 and the guest role is unchanged", async () => {
|
|
const res = await request(app)
|
|
.patch(`/api/users/${GUEST_USER_ID}/role`)
|
|
.set("Cookie", aliceCookie)
|
|
.send({ role: "admin" });
|
|
expect(res.status).toBe(404);
|
|
expect(users.findById(GUEST_USER_ID)!.role).toBe("guest");
|
|
});
|
|
|
|
it("POST /:id/reset-password → 404 (cannot give the guest a login)", async () => {
|
|
const res = await request(app)
|
|
.post(`/api/users/${GUEST_USER_ID}/reset-password`)
|
|
.set("Cookie", aliceCookie)
|
|
.send({ newPassword: "guest-new-pw" });
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it("GET /:id/permissions → 404", async () => {
|
|
const res = await request(app)
|
|
.get(`/api/users/${GUEST_USER_ID}/permissions`)
|
|
.set("Cookie", aliceCookie);
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it("PUT /:id/permissions → 404", async () => {
|
|
const res = await request(app)
|
|
.put(`/api/users/${GUEST_USER_ID}/permissions`)
|
|
.set("Cookie", aliceCookie)
|
|
.send({ capabilities: ["player.control"], bots: "all" });
|
|
expect(res.status).toBe(404);
|
|
});
|
|
});
|
|
});
|