mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
feat(spotify): expose spotify config on /api/bot/settings (secret masked) [S4.1]
This commit is contained in:
1 parent
eb3523f374
commit
b672d76634
2 files changed
+150
-2
No files matched your search
+111
-1
@@ -3,7 +3,7 @@ import express from "express";
|
|||||||
import cookieParser from "cookie-parser";
|
import cookieParser from "cookie-parser";
|
||||||
import request from "supertest";
|
import request from "supertest";
|
||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
import { mkdtempSync, rmSync } from "node:fs";
|
import { mkdtempSync, rmSync, readFileSync, existsSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { createDatabase, type BotDatabase } from "../../data/database.js";
|
import { createDatabase, type BotDatabase } from "../../data/database.js";
|
||||||
@@ -196,6 +196,105 @@ describe("bot router /settings", () => {
|
|||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(config.adminGroups).toEqual([6]);
|
expect(config.adminGroups).toEqual([6]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("GET /settings includes a masked spotify block (hasClientSecret, never a raw secret)", async () => {
|
||||||
|
config.spotify.enabled = true;
|
||||||
|
config.spotify.backend = "librespot";
|
||||||
|
config.spotify.clientId = "cid-1";
|
||||||
|
config.spotify.deviceName = "MyDevice";
|
||||||
|
config.spotify.bitrate = 160;
|
||||||
|
config.spotify.clientSecret = "supersecret";
|
||||||
|
|
||||||
|
const withSecret = await request(app).get("/api/bot/settings").set("Cookie", cookie);
|
||||||
|
expect(withSecret.status).toBe(200);
|
||||||
|
expect(withSecret.body.spotify).toEqual({
|
||||||
|
enabled: true,
|
||||||
|
backend: "librespot",
|
||||||
|
clientId: "cid-1",
|
||||||
|
deviceName: "MyDevice",
|
||||||
|
bitrate: 160,
|
||||||
|
hasClientSecret: true,
|
||||||
|
});
|
||||||
|
// The raw secret is never serialized to the client.
|
||||||
|
expect(withSecret.body.spotify).not.toHaveProperty("clientSecret");
|
||||||
|
|
||||||
|
config.spotify.clientSecret = "";
|
||||||
|
const noSecret = await request(app).get("/api/bot/settings").set("Cookie", cookie);
|
||||||
|
expect(noSecret.body.spotify.hasClientSecret).toBe(false);
|
||||||
|
expect(noSecret.body.spotify).not.toHaveProperty("clientSecret");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /settings updates the spotify block, echoes the masked view, and persists", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/bot/settings")
|
||||||
|
.set("Cookie", cookie)
|
||||||
|
.send({ spotify: { enabled: true, backend: "librespot", clientId: "cid", deviceName: "Dev", bitrate: 160 } });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(config.spotify.enabled).toBe(true);
|
||||||
|
expect(config.spotify.backend).toBe("librespot");
|
||||||
|
expect(config.spotify.clientId).toBe("cid");
|
||||||
|
expect(config.spotify.deviceName).toBe("Dev");
|
||||||
|
expect(config.spotify.bitrate).toBe(160);
|
||||||
|
|
||||||
|
expect(res.body.spotify).toEqual({
|
||||||
|
enabled: true,
|
||||||
|
backend: "librespot",
|
||||||
|
clientId: "cid",
|
||||||
|
deviceName: "Dev",
|
||||||
|
bitrate: 160,
|
||||||
|
hasClientSecret: false,
|
||||||
|
});
|
||||||
|
expect(res.body.spotify).not.toHaveProperty("clientSecret");
|
||||||
|
|
||||||
|
// saveConfig persisted the block to disk.
|
||||||
|
expect(existsSync(configPath)).toBe(true);
|
||||||
|
const persisted = JSON.parse(readFileSync(configPath, "utf-8"));
|
||||||
|
expect(persisted.spotify.clientId).toBe("cid");
|
||||||
|
expect(persisted.spotify.backend).toBe("librespot");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /settings ignores an invalid spotify backend/bitrate (partial-merge, no 400)", async () => {
|
||||||
|
config.spotify.backend = "auto";
|
||||||
|
config.spotify.bitrate = 320;
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/bot/settings")
|
||||||
|
.set("Cookie", cookie)
|
||||||
|
.send({ spotify: { backend: "bogus", bitrate: 999 } });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(config.spotify.backend).toBe("auto");
|
||||||
|
expect(config.spotify.bitrate).toBe(320);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /settings sets a non-empty clientSecret but a blank one never wipes it", async () => {
|
||||||
|
const set = await request(app)
|
||||||
|
.post("/api/bot/settings")
|
||||||
|
.set("Cookie", cookie)
|
||||||
|
.send({ spotify: { clientSecret: "newsecret" } });
|
||||||
|
expect(set.status).toBe(200);
|
||||||
|
expect(config.spotify.clientSecret).toBe("newsecret");
|
||||||
|
expect(set.body.spotify.hasClientSecret).toBe(true);
|
||||||
|
expect(set.body.spotify).not.toHaveProperty("clientSecret");
|
||||||
|
|
||||||
|
const blank = await request(app)
|
||||||
|
.post("/api/bot/settings")
|
||||||
|
.set("Cookie", cookie)
|
||||||
|
.send({ spotify: { clientSecret: "" } });
|
||||||
|
expect(blank.status).toBe(200);
|
||||||
|
expect(config.spotify.clientSecret).toBe("newsecret");
|
||||||
|
expect(blank.body.spotify.hasClientSecret).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /settings that omits spotify leaves config.spotify untouched (no regression)", async () => {
|
||||||
|
config.spotify.clientId = "keep-me";
|
||||||
|
config.spotify.clientSecret = "keep-secret";
|
||||||
|
const before = { ...config.spotify };
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/bot/settings")
|
||||||
|
.set("Cookie", cookie)
|
||||||
|
.send({ autoPauseOnEmpty: false });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(config.spotify).toEqual(before);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("bot router /settings guest-mode gating + persistence", () => {
|
describe("bot router /settings guest-mode gating + persistence", () => {
|
||||||
@@ -251,4 +350,15 @@ describe("bot router /settings guest-mode gating + persistence", () => {
|
|||||||
expect(res.body.guestMode.permissions.playNext).toBe(true);
|
expect(res.body.guestMode.permissions.playNext).toBe(true);
|
||||||
expect(res.body.guestMode.permissions.addToQueue).toBe(true); // untouched default
|
expect(res.body.guestMode.permissions.addToQueue).toBe(true); // untouched default
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("POST /settings spotify write is 403 for a member lacking bot.manage", async () => {
|
||||||
|
const memberApp = mountBot(() => ({ role: "member", capabilities: new Set([]) }));
|
||||||
|
const res = await request(memberApp)
|
||||||
|
.post("/api/bot/settings")
|
||||||
|
.send({ spotify: { enabled: true, clientId: "cid" } });
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
// Gate rejected before any mutation.
|
||||||
|
expect(config.spotify.enabled).toBe(false);
|
||||||
|
expect(config.spotify.clientId).toBe("");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+39
-1
@@ -1,6 +1,6 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import type { BotManager } from "../../bot/manager.js";
|
import type { BotManager } from "../../bot/manager.js";
|
||||||
import type { BotConfig, GuestModeConfig } from "../../data/config.js";
|
import type { BotConfig, GuestModeConfig, SpotifyConfig } from "../../data/config.js";
|
||||||
import { saveConfig } from "../../data/config.js";
|
import { saveConfig } from "../../data/config.js";
|
||||||
import type { Logger } from "../../logger.js";
|
import type { Logger } from "../../logger.js";
|
||||||
import type { BotDatabase } from "../../data/database.js";
|
import type { BotDatabase } from "../../data/database.js";
|
||||||
@@ -20,6 +20,17 @@ export function createBotRouter(
|
|||||||
): Router {
|
): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
// Masked spotify view shared by the GET response and the POST echo. The raw
|
||||||
|
// clientSecret is write-only and NEVER serialized — only whether one is stored.
|
||||||
|
const maskedSpotify = () => ({
|
||||||
|
enabled: config.spotify.enabled,
|
||||||
|
backend: config.spotify.backend,
|
||||||
|
clientId: config.spotify.clientId,
|
||||||
|
deviceName: config.spotify.deviceName,
|
||||||
|
bitrate: config.spotify.bitrate,
|
||||||
|
hasClientSecret: config.spotify.clientSecret.length > 0,
|
||||||
|
});
|
||||||
|
|
||||||
router.get("/", (req, res) => {
|
router.get("/", (req, res) => {
|
||||||
const all = botManager.getAllBots().map((b) => b.getStatus());
|
const all = botManager.getAllBots().map((b) => b.getStatus());
|
||||||
const u = req.user!;
|
const u = req.user!;
|
||||||
@@ -39,6 +50,7 @@ export function createBotRouter(
|
|||||||
localAudioEnabled: config.localAudioEnabled,
|
localAudioEnabled: config.localAudioEnabled,
|
||||||
adminGroups: config.adminGroups ?? [],
|
adminGroups: config.adminGroups ?? [],
|
||||||
guestMode: config.guestMode,
|
guestMode: config.guestMode,
|
||||||
|
spotify: maskedSpotify(),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,6 +97,31 @@ export function createBotRouter(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Partial-merge the spotify block (mirrors config.ts validation). Invalid
|
||||||
|
// sub-fields are ignored rather than 400-ing the whole request. Omitting
|
||||||
|
// `spotify` entirely leaves config.spotify untouched.
|
||||||
|
const VALID_BACKENDS = ["auto", "go-librespot", "librespot"] as const;
|
||||||
|
const VALID_BITRATES = [96, 160, 320];
|
||||||
|
const sp = req.body?.spotify;
|
||||||
|
if (sp && typeof sp === "object") {
|
||||||
|
const t = config.spotify;
|
||||||
|
if (typeof sp.enabled === "boolean") t.enabled = sp.enabled;
|
||||||
|
if (typeof sp.backend === "string" && (VALID_BACKENDS as readonly string[]).includes(sp.backend)) {
|
||||||
|
t.backend = sp.backend as SpotifyConfig["backend"];
|
||||||
|
}
|
||||||
|
if (typeof sp.clientId === "string") t.clientId = sp.clientId;
|
||||||
|
// Secret is write-only + set-on-non-empty so a blank field never wipes it.
|
||||||
|
if (typeof sp.clientSecret === "string" && sp.clientSecret.length > 0) {
|
||||||
|
t.clientSecret = sp.clientSecret;
|
||||||
|
}
|
||||||
|
if (typeof sp.deviceName === "string" && sp.deviceName.trim().length > 0) {
|
||||||
|
t.deviceName = sp.deviceName.trim();
|
||||||
|
}
|
||||||
|
if (typeof sp.bitrate === "number" && VALID_BITRATES.includes(sp.bitrate)) {
|
||||||
|
t.bitrate = sp.bitrate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
saveConfig(configPath, config);
|
saveConfig(configPath, config);
|
||||||
|
|
||||||
// Guest-mode changed: tear down / re-scope in-flight guest WS sockets so a
|
// Guest-mode changed: tear down / re-scope in-flight guest WS sockets so a
|
||||||
@@ -106,6 +143,7 @@ export function createBotRouter(
|
|||||||
localAudioEnabled: config.localAudioEnabled,
|
localAudioEnabled: config.localAudioEnabled,
|
||||||
adminGroups: config.adminGroups ?? [],
|
adminGroups: config.adminGroups ?? [],
|
||||||
guestMode: config.guestMode,
|
guestMode: config.guestMode,
|
||||||
|
spotify: maskedSpotify(),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user