diff --git a/src/web/api/bot.test.ts b/src/web/api/bot.test.ts index 02c9765..404942c 100644 --- a/src/web/api/bot.test.ts +++ b/src/web/api/bot.test.ts @@ -405,6 +405,97 @@ describe("bot router /settings applies spotify creds to the live OAuth (I2)", () }); }); +// R2-4: saving Spotify creds in Settings must also refresh the Web API SEARCH +// provider (spotifyProvider.setCreds), not only the OAuth playback path. Without +// this, a fresh install (enabled defaults false) keeps empty search creds until a +// full process restart even after an admin enters Client ID + Secret. +describe("bot router /settings refreshes the Web API search provider creds (R2-4)", () => { + let botDb: BotDatabase; + let app: express.Express; + let cookie: string; + let config: BotConfig; + let configPath: string; + let tmpDir: string; + let setCredsCalls: Array<[string, 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-provider-")); + configPath = join(tmpDir, "config.json"); + config = getDefaultConfig(); + + const fakeManager = { getAllBots: () => [] } as unknown as BotManager; + const avatarStore = createAvatarStore(tmpDir); + + // Fake search provider recording every setCreds(clientId, clientSecret) call. + setCredsCalls = []; + const fakeProvider = { + setCreds(clientId: string, clientSecret: string) { + setCredsCalls.push([clientId, clientSecret]); + }, + }; + // Fake OAuth so we can assert the playback path is still wired alongside. + 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, fakeProvider), + ); + }); + + afterEach(() => { + botDb.close(); + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("calls setCreds once with (clientId, clientSecret) when a spotify block is saved (and OAuth is still configured)", async () => { + const res = await request(app) + .post("/api/bot/settings") + .set("Cookie", cookie) + .send({ spotify: { clientId: "cid", clientSecret: "sec", enabled: true } }); + expect(res.status).toBe(200); + expect(setCredsCalls).toEqual([["cid", "sec"]]); + // The OAuth playback path is still wired on the same save. + expect(configureCalls.length).toBe(1); + }); + + it("does NOT call setCreds 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(setCredsCalls).toEqual([]); + }); + + it("calls setCreds with the PRESERVED stored secret when the spotify block omits/blanks clientSecret", async () => { + config.spotify.clientId = "cid0"; + config.spotify.clientSecret = "stored-secret"; + const res = await request(app) + .post("/api/bot/settings") + .set("Cookie", cookie) + .send({ spotify: { clientId: "cid0", clientSecret: "", enabled: true } }); + expect(res.status).toBe(200); + // Post-merge values: masked/blank secret must keep the stored one, never "". + expect(setCredsCalls).toEqual([["cid0", "stored-secret"]]); + }); +}); + describe("bot router /settings guest-mode gating + persistence", () => { let tmpDir: string; let configPath: string; diff --git a/src/web/api/bot.ts b/src/web/api/bot.ts index 9f239e0..d242da2 100755 --- a/src/web/api/bot.ts +++ b/src/web/api/bot.ts @@ -20,6 +20,11 @@ export function createBotRouter( // 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 }, + // R2-4: the process-wide Web API SEARCH provider. Boot only wires creds when + // spotify.enabled is already true, so a fresh install that enables Spotify via + // Settings must push creds here too, or search stays empty until a restart. + // Structural type = SpotifyProvider.setCreds. + spotifyProvider?: { setCreds(clientId: string, clientSecret: string): void }, ): Router { const router = Router(); @@ -135,6 +140,10 @@ export function createBotRouter( ? `http://127.0.0.1:${config.webPort}/api/spotify/callback` : undefined; spotifyOAuth?.configure(config.spotify.clientId, redirectUri); + // R2-4: also refresh the live Web API search provider so search/getAuthStatus + // work without a restart. Uses the post-merge values so a masked/omitted + // secret keeps the stored one. The secret is never logged. + spotifyProvider?.setCreds(config.spotify.clientId, config.spotify.clientSecret); } // Guest-mode changed: tear down / re-scope in-flight guest WS sockets so a diff --git a/src/web/server.ts b/src/web/server.ts index d491158..78c1a9f 100755 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -21,6 +21,7 @@ import { createAuditRouter } from "./api/audit.js"; import { createFavoritesRouter } from "./api/favorites.js"; import { createSpotifyRouter } from "./api/spotify.js"; import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js"; +import type { SpotifyProvider } from "../music/spotify/provider.js"; import { resolveSpotifyBackendKind } from "../music/spotify/backend-select.js"; import { isGoLibrespotPresent, @@ -135,6 +136,11 @@ export function createWebServer(options: WebServerOptions): WebServer { // 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, + // R2-4: so saving Spotify creds in Settings also refreshes the live Web API + // search provider (search + getAuthStatus) without a process restart. The + // runtime object is a SpotifyProvider (see index.ts); WebServerOptions types + // it as the wider MusicProvider, so narrow it here for the setCreds contract. + options.spotifyProvider as SpotifyProvider, ) ); app.use(