feat(spotify): expose spotify config on /api/bot/settings (secret masked) [S4.1]

This commit is contained in:
saopig1 committed 2026-07-03 00:41:29 +08:00
1 parent eb3523f374
commit b672d76634
2 files changed
+150 -2

No files matched your search

+111 -1
View File
@@ -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
View File
@@ -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(),
}); });
}); });