mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
- New guest flag playCollection (default OFF), gates play-playlist/play-album - Keeps playNow's non-destructive semantics intact (Play All clears the queue) - Admin-toggleable in Settings → 游客模式; default-off, backward-compatible - Frontend: gate the 播放全部 button on the flag + surface 403 as a toast instead of failing silently (the silent-failure half of the issue)
459 lines
20 KiB
TypeScript
459 lines
20 KiB
TypeScript
import { describe, it, expect, beforeEach } from "vitest";
|
|
import express from "express";
|
|
import request from "supertest";
|
|
import pino from "pino";
|
|
import { createPlayerRouter } from "./player.js";
|
|
import { createBotRouter } from "./bot.js";
|
|
import { createAuthRouter } from "./auth.js";
|
|
import { createMusicRouter } from "./music.js";
|
|
import { createFavoritesRouter } from "./favorites.js";
|
|
import { requireNotGuest } from "../middleware/requireNotGuest.js";
|
|
|
|
const logger = pino({ level: "silent" });
|
|
|
|
// --- minimal stubs --------------------------------------------------------
|
|
|
|
const ALLOWED_BOT = "bot-allowed";
|
|
|
|
// A fake bot whose methods all no-op / return benign values so the real
|
|
// handlers run to completion without 500ing. We only assert that the
|
|
// permission/bot-access gate let the request THROUGH (status !== 403).
|
|
function makeFakeBot(id: string) {
|
|
return {
|
|
id,
|
|
executeCommand: async () => "ok",
|
|
getStatus: () => ({ id }),
|
|
getQueue: () => [],
|
|
getProfileManager: () => ({ getConfig: () => ({}), updateConfig: () => {}, setCustomAvatar: () => {} }),
|
|
};
|
|
}
|
|
|
|
function makeBotManager() {
|
|
const bot = makeFakeBot(ALLOWED_BOT);
|
|
return {
|
|
getBot: (id: string) => (id === ALLOWED_BOT ? bot : undefined),
|
|
getAllBots: () => [bot],
|
|
getBotConfig: () => undefined,
|
|
createBot: async () => bot,
|
|
updateBot: () => {},
|
|
removeBot: async () => {},
|
|
startBot: async () => {},
|
|
stopBot: () => {},
|
|
} as any;
|
|
}
|
|
|
|
function makeProvider() {
|
|
return {
|
|
platform: "netease",
|
|
getQuality: () => "high",
|
|
setQuality: () => {},
|
|
getAuthStatus: async () => ({ loggedIn: false }),
|
|
getQrCode: async () => ({ key: "k", url: "u" }),
|
|
getCookie: () => "c",
|
|
setCookie: () => {},
|
|
search: async () => ({ songs: [], albums: [], playlists: [] }),
|
|
} as any;
|
|
}
|
|
|
|
// Build one app mounting all four real routers, with req.user injected by a
|
|
// middleware placed BEFORE the routers (mimicking what requireAuth does).
|
|
function makeApp(user: any) {
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use((req, _res, next) => { (req as any).user = user; next(); });
|
|
|
|
const botManager = makeBotManager();
|
|
const provider = makeProvider();
|
|
|
|
app.use("/api/player", createPlayerRouter(botManager, logger));
|
|
app.use(
|
|
"/api/bot",
|
|
createBotRouter(
|
|
botManager,
|
|
{ idleTimeoutMinutes: 0 } as any,
|
|
"/tmp/config.json",
|
|
logger,
|
|
{ getBotInstances: () => [], getCustomAvatarPath: () => null, setCustomAvatarPath: () => {} } as any,
|
|
{ read: () => null, write: () => "x", remove: () => {} } as any,
|
|
),
|
|
);
|
|
app.use("/api/auth", createAuthRouter(provider, provider, provider, logger));
|
|
app.use("/api/music", createMusicRouter(provider, provider, provider, logger));
|
|
|
|
return app;
|
|
}
|
|
|
|
const member = (caps: string[], bots: "all" | string[]) => ({
|
|
id: "u1",
|
|
username: "alice",
|
|
role: "member" as const,
|
|
capabilities: new Set(caps),
|
|
bots: bots === "all" ? ("all" as const) : new Set(bots),
|
|
});
|
|
|
|
const admin = {
|
|
id: "a",
|
|
username: "admin",
|
|
role: "admin" as const,
|
|
capabilities: new Set<string>(),
|
|
bots: "all" as const,
|
|
};
|
|
|
|
describe("permission enforcement on action routes", () => {
|
|
describe("player.control", () => {
|
|
it("403 for member WITHOUT player.control", async () => {
|
|
const app = makeApp(member([], [ALLOWED_BOT]));
|
|
const res = await request(app).post(`/api/player/${ALLOWED_BOT}/pause`);
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("NOT 403 for member WITH player.control + bot in allow-list", async () => {
|
|
const app = makeApp(member(["player.control"], [ALLOWED_BOT]));
|
|
const res = await request(app).post(`/api/player/${ALLOWED_BOT}/pause`);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("403 for member WITH player.control but bot NOT in allow-list", async () => {
|
|
const app = makeApp(member(["player.control"], ["other-bot"]));
|
|
const res = await request(app).post(`/api/player/${ALLOWED_BOT}/pause`);
|
|
expect(res.status).toBe(403);
|
|
});
|
|
});
|
|
|
|
describe("player.queue", () => {
|
|
it("403 for member WITHOUT player.queue", async () => {
|
|
const app = makeApp(member(["player.control"], [ALLOWED_BOT]));
|
|
const res = await request(app).post(`/api/player/${ALLOWED_BOT}/clear`);
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("NOT 403 for member WITH player.queue", async () => {
|
|
const app = makeApp(member(["player.queue"], [ALLOWED_BOT]));
|
|
const res = await request(app).post(`/api/player/${ALLOWED_BOT}/clear`);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
});
|
|
|
|
describe("bot.manage", () => {
|
|
it("403 for member WITHOUT bot.manage on POST /api/bot", async () => {
|
|
const app = makeApp(member([], "all"));
|
|
const res = await request(app)
|
|
.post("/api/bot")
|
|
.send({ name: "n", serverAddress: "s", nickname: "nick" });
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("NOT 403 for member WITH bot.manage on POST /api/bot", async () => {
|
|
const app = makeApp(member(["bot.manage"], "all"));
|
|
const res = await request(app)
|
|
.post("/api/bot")
|
|
.send({ name: "n", serverAddress: "s", nickname: "nick" });
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("403 for member WITH bot.manage but bot NOT in allow-list on POST /api/bot/:id/start", async () => {
|
|
const app = makeApp(member(["bot.manage"], ["other-bot"]));
|
|
const res = await request(app).post(`/api/bot/${ALLOWED_BOT}/start`);
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("NOT 403 for member WITH bot.manage + bot in allow-list on POST /api/bot/:id/start", async () => {
|
|
const app = makeApp(member(["bot.manage"], [ALLOWED_BOT]));
|
|
const res = await request(app).post(`/api/bot/${ALLOWED_BOT}/start`);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
});
|
|
|
|
describe("platform.auth", () => {
|
|
it("403 for member WITHOUT platform.auth on POST /api/auth/cookie", async () => {
|
|
const app = makeApp(member([], "all"));
|
|
const res = await request(app).post("/api/auth/cookie").send({ cookie: "c" });
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("NOT 403 for member WITH platform.auth on POST /api/auth/cookie", async () => {
|
|
const app = makeApp(member(["platform.auth"], "all"));
|
|
const res = await request(app).post("/api/auth/cookie").send({ cookie: "c" });
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
});
|
|
|
|
describe("quality", () => {
|
|
it("403 for member WITHOUT quality on POST /api/music/quality", async () => {
|
|
const app = makeApp(member([], "all"));
|
|
const res = await request(app).post("/api/music/quality").send({ quality: "high" });
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("NOT 403 for member WITH quality on POST /api/music/quality", async () => {
|
|
const app = makeApp(member(["quality"], "all"));
|
|
const res = await request(app).post("/api/music/quality").send({ quality: "high" });
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("GET /api/music/quality is 403 for guests, allowed for members", async () => {
|
|
const guestApp = makeApp(guest());
|
|
expect((await request(guestApp).get("/api/music/quality")).status).toBe(403);
|
|
const memberApp = makeApp(member([], "all"));
|
|
expect((await request(memberApp).get("/api/music/quality")).status).toBe(200);
|
|
});
|
|
});
|
|
|
|
// The operator's personal-account reads (their recommendations, FM, and
|
|
// playlists) must never leak to login-less guests. These routes are gated
|
|
// with requireNotGuest; generic search/browse stays open.
|
|
describe("operator personal-data reads are denied to guests", () => {
|
|
const personalRoutes = [
|
|
"/api/music/recommend/songs",
|
|
"/api/music/personal/fm",
|
|
"/api/music/user/playlists",
|
|
];
|
|
|
|
for (const route of personalRoutes) {
|
|
it(`GET ${route} is 403 for a guest`, async () => {
|
|
const app = makeApp(guest());
|
|
expect((await request(app).get(route)).status).toBe(403);
|
|
});
|
|
|
|
it(`GET ${route} is NOT 403 for a member`, async () => {
|
|
const app = makeApp(member([], "all"));
|
|
expect((await request(app).get(route)).status).not.toBe(403);
|
|
});
|
|
}
|
|
});
|
|
|
|
describe("read-only routes stay open", () => {
|
|
it("GET /api/auth/status not gated", async () => {
|
|
const app = makeApp(member([], "all"));
|
|
const res = await request(app).get("/api/auth/status");
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("GET /api/music/quality readable by members, denied to guests", async () => {
|
|
const app = makeApp(member([], "all"));
|
|
const res = await request(app).get("/api/music/quality");
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("GET /api/bot not gated", async () => {
|
|
const app = makeApp(member([], "all"));
|
|
const res = await request(app).get("/api/bot");
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("GET /api/auth/status and /api/auth/qrcode/status are 403 for guests", async () => {
|
|
const app = makeApp(guest());
|
|
expect((await request(app).get("/api/auth/status")).status).toBe(403);
|
|
expect((await request(app).get("/api/auth/qrcode/status?key=k")).status).toBe(403);
|
|
});
|
|
});
|
|
|
|
describe("admin bypasses every gate", () => {
|
|
let app: express.Express;
|
|
beforeEach(() => { app = makeApp(admin); });
|
|
|
|
it("player.control", async () => {
|
|
expect((await request(app).post(`/api/player/${ALLOWED_BOT}/pause`)).status).not.toBe(403);
|
|
});
|
|
it("player.queue", async () => {
|
|
expect((await request(app).post(`/api/player/${ALLOWED_BOT}/clear`)).status).not.toBe(403);
|
|
});
|
|
it("bot.manage POST /api/bot", async () => {
|
|
const res = await request(app).post("/api/bot").send({ name: "n", serverAddress: "s", nickname: "nick" });
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
it("bot.manage POST /api/bot/:id/start", async () => {
|
|
expect((await request(app).post(`/api/bot/${ALLOWED_BOT}/start`)).status).not.toBe(403);
|
|
});
|
|
it("platform.auth POST /api/auth/cookie", async () => {
|
|
expect((await request(app).post("/api/auth/cookie").send({ cookie: "c" })).status).not.toBe(403);
|
|
});
|
|
it("quality POST /api/music/quality", async () => {
|
|
expect((await request(app).post("/api/music/quality").send({ quality: "high" })).status).not.toBe(403);
|
|
});
|
|
});
|
|
});
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Guest enforcement on the player routes. Guests carry per-flag permissions
|
|
// (req.user.guest) instead of capabilities; authorize() opens a route only
|
|
// when its guestFlag is set AND enabled. Routes with no guestFlag are denied
|
|
// to guests no matter which flags are on. We reuse makeApp() (it injects
|
|
// req.user and mounts the real player router over the fake bot manager) and
|
|
// assert purely on 403-vs-not-403 — a 200/500 from the fake bot both prove
|
|
// the gate let the request through.
|
|
// --------------------------------------------------------------------------
|
|
|
|
const SONG = { id: "1", platform: "netease", name: "x", artist: "y" };
|
|
|
|
// Build a guest user with all flags off, then override the ones passed in.
|
|
const guest = (perms: Partial<Record<string, boolean>> = {}) => ({
|
|
id: "__guest__",
|
|
username: "游客",
|
|
role: "guest" as const,
|
|
capabilities: new Set<string>(),
|
|
bots: "all" as const,
|
|
guest: {
|
|
addToQueue: false,
|
|
playNext: false,
|
|
playNow: false,
|
|
skip: false,
|
|
transport: false,
|
|
removeClear: false,
|
|
playMode: false,
|
|
playCollection: false,
|
|
...perms,
|
|
},
|
|
});
|
|
|
|
const mountGuest = (perms: Partial<Record<string, boolean>> = {}) => makeApp(guest(perms));
|
|
|
|
describe("guest enforcement on player routes", () => {
|
|
it("addToQueue flag gates POST /add, /add-song, /add-by-id", async () => {
|
|
const allow = mountGuest({ addToQueue: true });
|
|
const deny = mountGuest({ addToQueue: false });
|
|
for (const path of ["add", "add-song", "add-by-id"]) {
|
|
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/${path}`).send({ song: SONG, songId: "1", query: "x" })).status).not.toBe(403);
|
|
expect((await request(deny).post(`/api/player/${ALLOWED_BOT}/${path}`).send({ song: SONG, songId: "1", query: "x" })).status).toBe(403);
|
|
}
|
|
});
|
|
|
|
it("playNext flag gates /play-next-song", async () => {
|
|
expect((await request(mountGuest({ playNext: true })).post(`/api/player/${ALLOWED_BOT}/play-next-song`).send({ song: SONG })).status).not.toBe(403);
|
|
expect((await request(mountGuest({})).post(`/api/player/${ALLOWED_BOT}/play-next-song`).send({ song: SONG })).status).toBe(403);
|
|
});
|
|
|
|
it("playNow flag gates the new /play-now-song", async () => {
|
|
expect((await request(mountGuest({ playNow: true })).post(`/api/player/${ALLOWED_BOT}/play-now-song`).send({ song: SONG })).status).not.toBe(403);
|
|
expect((await request(mountGuest({})).post(`/api/player/${ALLOWED_BOT}/play-now-song`).send({ song: SONG })).status).toBe(403);
|
|
// playNext does NOT open play-now-song, and playNow does NOT open play-next-song.
|
|
expect((await request(mountGuest({ playNext: true })).post(`/api/player/${ALLOWED_BOT}/play-now-song`).send({ song: SONG })).status).toBe(403);
|
|
expect((await request(mountGuest({ playNow: true })).post(`/api/player/${ALLOWED_BOT}/play-next-song`).send({ song: SONG })).status).toBe(403);
|
|
});
|
|
|
|
it("skip flag gates /next", async () => {
|
|
expect((await request(mountGuest({ skip: true })).post(`/api/player/${ALLOWED_BOT}/next`)).status).not.toBe(403);
|
|
expect((await request(mountGuest({})).post(`/api/player/${ALLOWED_BOT}/next`)).status).toBe(403);
|
|
});
|
|
|
|
it("transport flag gates /pause, /resume, /seek, /volume", async () => {
|
|
const allow = mountGuest({ transport: true });
|
|
const deny = mountGuest({ transport: false });
|
|
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/pause`)).status).not.toBe(403);
|
|
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/resume`)).status).not.toBe(403);
|
|
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/seek`).send({ position: 0 })).status).not.toBe(403);
|
|
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/volume`).send({ volume: 50 })).status).not.toBe(403);
|
|
expect((await request(deny).post(`/api/player/${ALLOWED_BOT}/pause`)).status).toBe(403);
|
|
expect((await request(deny).post(`/api/player/${ALLOWED_BOT}/resume`)).status).toBe(403);
|
|
expect((await request(deny).post(`/api/player/${ALLOWED_BOT}/seek`).send({ position: 0 })).status).toBe(403);
|
|
expect((await request(deny).post(`/api/player/${ALLOWED_BOT}/volume`).send({ volume: 50 })).status).toBe(403);
|
|
});
|
|
|
|
it("playMode flag gates /mode, /fm", async () => {
|
|
const allow = mountGuest({ playMode: true });
|
|
const deny = mountGuest({ playMode: false });
|
|
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/mode`).send({ mode: "seq" })).status).not.toBe(403);
|
|
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/fm`).send({})).status).not.toBe(403);
|
|
expect((await request(deny).post(`/api/player/${ALLOWED_BOT}/mode`).send({ mode: "seq" })).status).toBe(403);
|
|
expect((await request(deny).post(`/api/player/${ALLOWED_BOT}/fm`).send({})).status).toBe(403);
|
|
});
|
|
|
|
it("removeClear flag gates /clear and DELETE /queue/:index", async () => {
|
|
const allow = mountGuest({ removeClear: true });
|
|
const deny = mountGuest({ removeClear: false });
|
|
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/clear`)).status).not.toBe(403);
|
|
expect((await request(allow).delete(`/api/player/${ALLOWED_BOT}/queue/0`)).status).not.toBe(403);
|
|
expect((await request(deny).post(`/api/player/${ALLOWED_BOT}/clear`)).status).toBe(403);
|
|
expect((await request(deny).delete(`/api/player/${ALLOWED_BOT}/queue/0`)).status).toBe(403);
|
|
});
|
|
|
|
it("each guest flag opens exactly its own route(s) — a single flag does not leak", async () => {
|
|
// With only addToQueue on, a transport route stays denied.
|
|
expect((await request(mountGuest({ addToQueue: true })).post(`/api/player/${ALLOWED_BOT}/pause`)).status).toBe(403);
|
|
// With only transport on, an add route stays denied.
|
|
expect((await request(mountGuest({ transport: true })).post(`/api/player/${ALLOWED_BOT}/add-song`).send({ song: SONG })).status).toBe(403);
|
|
});
|
|
|
|
it("playCollection flag gates /play-playlist, /play-album (issue #103)", async () => {
|
|
const allow = mountGuest({ playCollection: true });
|
|
const deny = mountGuest({ playCollection: false });
|
|
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/play-playlist`).send({ playlistId: "1" })).status).not.toBe(403);
|
|
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/play-album`).send({ albumId: "1" })).status).not.toBe(403);
|
|
expect((await request(deny).post(`/api/player/${ALLOWED_BOT}/play-playlist`).send({ playlistId: "1" })).status).toBe(403);
|
|
expect((await request(deny).post(`/api/player/${ALLOWED_BOT}/play-album`).send({ albumId: "1" })).status).toBe(403);
|
|
// playCollection does NOT leak into the destructive single-song / queue ops.
|
|
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/play`).send({ query: "x" })).status).toBe(403);
|
|
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/play-song`).send({ song: SONG })).status).toBe(403);
|
|
});
|
|
|
|
it("guests are always denied /play, /prev, /stop, /play-song, /play-at, /playlist, /profile even with ALL flags on", async () => {
|
|
const all = mountGuest({
|
|
addToQueue: true,
|
|
playNext: true,
|
|
playNow: true,
|
|
skip: true,
|
|
transport: true,
|
|
removeClear: true,
|
|
playMode: true,
|
|
playCollection: true,
|
|
});
|
|
expect((await request(all).post(`/api/player/${ALLOWED_BOT}/play`).send({ query: "x" })).status).toBe(403);
|
|
expect((await request(all).post(`/api/player/${ALLOWED_BOT}/prev`)).status).toBe(403);
|
|
expect((await request(all).post(`/api/player/${ALLOWED_BOT}/stop`)).status).toBe(403);
|
|
expect((await request(all).post(`/api/player/${ALLOWED_BOT}/play-song`).send({ song: SONG })).status).toBe(403);
|
|
expect((await request(all).post(`/api/player/${ALLOWED_BOT}/play-at`).send({ index: 0 })).status).toBe(403);
|
|
expect((await request(all).post(`/api/player/${ALLOWED_BOT}/playlist`).send({ playlistId: "1" })).status).toBe(403);
|
|
expect((await request(all).put(`/api/player/${ALLOWED_BOT}/profile`).send({})).status).toBe(403);
|
|
});
|
|
|
|
it("members are unaffected — player.queue still reaches /add-song", async () => {
|
|
const m = makeApp(member(["player.queue"], [ALLOWED_BOT]));
|
|
expect((await request(m).post(`/api/player/${ALLOWED_BOT}/add-song`).send({ song: SONG })).status).not.toBe(403);
|
|
});
|
|
});
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Favorites are member-only: the router keys everything off req.user.id and
|
|
// all guests share the __guest__ principal, so a guest must never reach it.
|
|
// server.ts gates the mount with requireNotGuest; we mirror that mount here
|
|
// and assert a guest gets 403 (the requireNotGuest guard runs before any
|
|
// handler, so the fake database is never touched).
|
|
// --------------------------------------------------------------------------
|
|
|
|
function makeFavoritesApp(user: any) {
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use((req, _res, next) => { (req as any).user = user; next(); });
|
|
const fakeDb = {
|
|
getFavorites: () => [],
|
|
addFavorite: () => {},
|
|
removeFavorite: () => {},
|
|
isFavorited: () => false,
|
|
} as any;
|
|
app.use("/api/favorites", requireNotGuest, createFavoritesRouter(fakeDb, logger));
|
|
return app;
|
|
}
|
|
|
|
describe("favorites are denied to guests", () => {
|
|
it("403 for a guest on GET /api/favorites", async () => {
|
|
const app = makeFavoritesApp(guest());
|
|
expect((await request(app).get("/api/favorites")).status).toBe(403);
|
|
});
|
|
|
|
it("403 for a guest on GET /api/favorites/check", async () => {
|
|
const app = makeFavoritesApp(guest());
|
|
expect((await request(app).get("/api/favorites/check?platform=netease&playlistId=x")).status).toBe(403);
|
|
});
|
|
|
|
it("403 for a guest on POST /api/favorites", async () => {
|
|
const app = makeFavoritesApp(guest());
|
|
const res = await request(app).post("/api/favorites").send({ platform: "netease", playlistId: "x", name: "n" });
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("NOT 403 for a member on GET /api/favorites", async () => {
|
|
const app = makeFavoritesApp(member([], "all"));
|
|
expect((await request(app).get("/api/favorites")).status).not.toBe(403);
|
|
});
|
|
});
|