mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +08:00
fix(spotify): apply UI-entered Client ID to live SpotifyOAuth without restart [whole-branch I2]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
399cf0cf41
commit
d796dd48ff
5 files changed
+149
No files matched your search
@@ -409,6 +409,53 @@ describe("SpotifyOAuth PKCE verifier TTL + cap (S4.3)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Whole-branch I2: a UI-entered Client ID (saved in Settings) must reach the
|
||||||
|
// single live SpotifyOAuth WITHOUT a process restart. configure() re-arms the
|
||||||
|
// runtime credentials; an empty clientId re-disables OAuth.
|
||||||
|
describe("SpotifyOAuth.configure (runtime credentials, whole-branch I2)", () => {
|
||||||
|
it("applies a UI-entered clientId + redirectUri so OAuth arms without a restart", () => {
|
||||||
|
// Fresh install: boot-time config had no clientId, so OAuth is disabled.
|
||||||
|
const store = memStore({
|
||||||
|
accessToken: "a",
|
||||||
|
refreshToken: "r",
|
||||||
|
expiresAt: Date.now() + 60_000,
|
||||||
|
scope: "s",
|
||||||
|
});
|
||||||
|
const oauth = new SpotifyOAuth({ clientId: "", store });
|
||||||
|
expect(oauth.isAuthorized()).toBe(false);
|
||||||
|
expect(() => oauth.buildAuthorizeUrl()).toThrow(/Client ID/i);
|
||||||
|
|
||||||
|
// Operator enters creds in Settings -> POST /settings calls configure().
|
||||||
|
const REDIRECT = "http://127.0.0.1:3000/api/spotify/callback";
|
||||||
|
oauth.configure("cid", REDIRECT);
|
||||||
|
expect(oauth.getClientId()).toBe("cid");
|
||||||
|
expect(oauth.getRedirectUri()).toBe(REDIRECT);
|
||||||
|
// Now authorize + isAuthorized work against the newly-supplied app.
|
||||||
|
expect(oauth.isAuthorized()).toBe(true);
|
||||||
|
const { url } = oauth.buildAuthorizeUrl();
|
||||||
|
const p = new URL(url).searchParams;
|
||||||
|
expect(p.get("client_id")).toBe("cid");
|
||||||
|
expect(p.get("redirect_uri")).toBe(REDIRECT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims the clientId and re-disables OAuth when cleared", () => {
|
||||||
|
const oauth = new SpotifyOAuth({
|
||||||
|
clientId: "old",
|
||||||
|
redirectUri: "http://old",
|
||||||
|
store: memStore(),
|
||||||
|
});
|
||||||
|
oauth.configure(" cid ", "http://127.0.0.1:3000/api/spotify/callback");
|
||||||
|
expect(oauth.getClientId()).toBe("cid"); // trimmed
|
||||||
|
|
||||||
|
// Empty clientId disables OAuth again (gate on buildAuthorizeUrl/isAuthorized).
|
||||||
|
oauth.configure("");
|
||||||
|
expect(oauth.getClientId()).toBe("");
|
||||||
|
expect(oauth.getRedirectUri()).toBe("");
|
||||||
|
expect(oauth.isAuthorized()).toBe(false);
|
||||||
|
expect(() => oauth.buildAuthorizeUrl()).toThrow(/Client ID/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("createFileOAuthTokenStore", () => {
|
describe("createFileOAuthTokenStore", () => {
|
||||||
it("round-trips save/load and clear() removes it", () => {
|
it("round-trips save/load and clear() removes it", () => {
|
||||||
const dir = mkdtempSync(join(tmpdir(), "sp-oauth-"));
|
const dir = mkdtempSync(join(tmpdir(), "sp-oauth-"));
|
||||||
|
|||||||
@@ -148,6 +148,14 @@ export class SpotifyOAuth {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Update the operator's app credentials at runtime (from Settings save) so
|
||||||
|
* a UI-entered Client ID takes effect without a process restart. Empty
|
||||||
|
* clientId disables OAuth (isAuthorized()/buildAuthorizeUrl() gate on it). */
|
||||||
|
configure(clientId?: string, redirectUri?: string): void {
|
||||||
|
this.clientId = clientId?.trim() || "";
|
||||||
|
this.redirectUri = redirectUri || "";
|
||||||
|
}
|
||||||
|
|
||||||
getClientId(): string {
|
getClientId(): string {
|
||||||
return this.clientId;
|
return this.clientId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -297,6 +297,84 @@ describe("bot router /settings", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Whole-branch I2: saving a Client ID in Settings must re-configure the single
|
||||||
|
// live SpotifyOAuth so the operator can Connect without a process restart.
|
||||||
|
describe("bot router /settings applies spotify creds to the live OAuth (I2)", () => {
|
||||||
|
let botDb: BotDatabase;
|
||||||
|
let app: express.Express;
|
||||||
|
let cookie: string;
|
||||||
|
let config: BotConfig;
|
||||||
|
let configPath: string;
|
||||||
|
let tmpDir: string;
|
||||||
|
let configureCalls: Array<[string | undefined, string | undefined]>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
botDb = createDatabase(":memory:");
|
||||||
|
const users = createUserStore(botDb.db);
|
||||||
|
const sessions = createSessionStore(botDb.db);
|
||||||
|
const alice = await users.createUser("alice", "pw-alice", "admin");
|
||||||
|
cookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(alice.id).token}`;
|
||||||
|
|
||||||
|
tmpDir = mkdtempSync(join(tmpdir(), "botsettings-oauth-"));
|
||||||
|
configPath = join(tmpDir, "config.json");
|
||||||
|
config = getDefaultConfig(); // webPort defaults to 3000
|
||||||
|
|
||||||
|
const fakeManager = { getAllBots: () => [] } as unknown as BotManager;
|
||||||
|
const avatarStore = createAvatarStore(tmpDir);
|
||||||
|
|
||||||
|
// Fake OAuth recording every configure(clientId, redirectUri) call.
|
||||||
|
configureCalls = [];
|
||||||
|
const fakeOAuth = {
|
||||||
|
configure(clientId?: string, redirectUri?: string) {
|
||||||
|
configureCalls.push([clientId, redirectUri]);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(cookieParser());
|
||||||
|
app.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db), () => getDefaultConfig().guestMode));
|
||||||
|
app.use(
|
||||||
|
"/api/bot",
|
||||||
|
createBotRouter(fakeManager, config, configPath, pino({ level: "silent" }), botDb, avatarStore, undefined, fakeOAuth),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
botDb.close();
|
||||||
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("configures the live OAuth once with the derived callback redirectUri when a Client ID is saved", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/bot/settings")
|
||||||
|
.set("Cookie", cookie)
|
||||||
|
.send({ spotify: { clientId: "cid", enabled: true } });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(configureCalls).toEqual([
|
||||||
|
["cid", `http://127.0.0.1:${config.webPort}/api/spotify/callback`],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT touch the OAuth when the request has no spotify block", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/bot/settings")
|
||||||
|
.set("Cookie", cookie)
|
||||||
|
.send({ autoPauseOnEmpty: false });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(configureCalls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("configures with ('', undefined) when a spotify block clears the Client ID (disables OAuth)", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/bot/settings")
|
||||||
|
.set("Cookie", cookie)
|
||||||
|
.send({ spotify: { clientId: "" } });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(configureCalls).toEqual([["", undefined]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("bot router /settings guest-mode gating + persistence", () => {
|
describe("bot router /settings guest-mode gating + persistence", () => {
|
||||||
let tmpDir: string;
|
let tmpDir: string;
|
||||||
let configPath: string;
|
let configPath: string;
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ export function createBotRouter(
|
|||||||
botDb: BotDatabase,
|
botDb: BotDatabase,
|
||||||
avatarStore: AvatarStore,
|
avatarStore: AvatarStore,
|
||||||
onGuestPolicyChanged?: (cfg: GuestModeConfig) => void,
|
onGuestPolicyChanged?: (cfg: GuestModeConfig) => void,
|
||||||
|
// I2: the single process-wide OAuth, so a UI-entered Client ID reaches the
|
||||||
|
// live instance on save (no restart). Structural type = SpotifyOAuth.configure.
|
||||||
|
spotifyOAuth?: { configure(clientId?: string, redirectUri?: string): void },
|
||||||
): Router {
|
): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -124,6 +127,16 @@ export function createBotRouter(
|
|||||||
|
|
||||||
saveConfig(configPath, config);
|
saveConfig(configPath, config);
|
||||||
|
|
||||||
|
// I2: only when the spotify block was present, push the (possibly UI-entered)
|
||||||
|
// Client ID into the live process-wide OAuth so Connect works without a
|
||||||
|
// restart. Empty clientId => undefined redirect => configure() disables OAuth.
|
||||||
|
if (sp && typeof sp === "object") {
|
||||||
|
const redirectUri = config.spotify.clientId
|
||||||
|
? `http://127.0.0.1:${config.webPort}/api/spotify/callback`
|
||||||
|
: undefined;
|
||||||
|
spotifyOAuth?.configure(config.spotify.clientId, redirectUri);
|
||||||
|
}
|
||||||
|
|
||||||
// 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
|
||||||
// disabled or narrowed scope takes effect immediately (matches requireAuth's
|
// disabled or narrowed scope takes effect immediately (matches requireAuth's
|
||||||
// "disabling immediately invalidates in-flight guest sessions" invariant).
|
// "disabling immediately invalidates in-flight guest sessions" invariant).
|
||||||
|
|||||||
@@ -132,6 +132,9 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
|||||||
options.database,
|
options.database,
|
||||||
options.avatarStore,
|
options.avatarStore,
|
||||||
(cfg) => onGuestPolicyChanged(cfg),
|
(cfg) => onGuestPolicyChanged(cfg),
|
||||||
|
// I2: so saving a Client ID in Settings re-configures the live OAuth
|
||||||
|
// (no restart needed for the UI-entered-creds -> Connect flow).
|
||||||
|
options.spotifyOAuth,
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
app.use(
|
app.use(
|
||||||
|
|||||||
Reference in new issue
Block a user