diff --git a/server/spotify.mjs b/server/spotify.mjs index 8eb136f..43c4b99 100644 --- a/server/spotify.mjs +++ b/server/spotify.mjs @@ -106,7 +106,17 @@ export async function spotifyApi(path, { method = 'GET', body, signal } = {}) { headers: { Authorization: `Bearer ${token}`, ...(body ? { 'Content-Type': 'application/json' } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}), }); - if (response.status === 403) return { error: 'spotify_permission_required', hint: 'Переподключи Spotify в пульте, чтобы разрешить работу с плейлистами.' }; + if (response.status === 403) { + const failure = await response.json().catch(() => null); + const reason = `${response.headers.get('www-authenticate') || ''} ${failure?.error?.message || ''}`; + if (/insufficient[_ ](?:client )?scope/i.test(reason)) { + return { error: 'spotify_permission_required', hint: 'Переподключи Spotify в пульте, чтобы разрешить работу с плейлистами.' }; + } + if (path.startsWith('playlists/')) { + return { error: 'spotify_playlist_access_denied', hint: 'Spotify разрешает читать и менять состав плейлиста его владельцу и соавторам.' }; + } + return { error: 'spotify_access_denied' }; + } if (!response.ok) return { error: 'spotify_request_failed' }; return response.status === 204 ? { ok: true } : response.json(); } diff --git a/test/spotify.test.mjs b/test/spotify.test.mjs index ea3c83f..657fc24 100644 --- a/test/spotify.test.mjs +++ b/test/spotify.test.mjs @@ -4,7 +4,7 @@ import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createHash } from 'node:crypto'; -import { startSpotify, finishSpotify, spotifyStatus, searchSpotify } from '../server/spotify.mjs'; +import { startSpotify, finishSpotify, spotifyStatus, searchSpotify, playlistTool } from '../server/spotify.mjs'; test('PKCE state is single use and token persists privately; exact artist ranks first', async () => { const dir = mkdtempSync(join(tmpdir(), 'spotify-test-')); @@ -16,6 +16,12 @@ test('PKCE state is single use and token persists privately; exact artist ranks verifier = options.body.get('code_verifier'); return Response.json({ access_token: 'access', refresh_token: 'refresh', expires_in: 3600 }); } + if (String(url).includes('/me/playlists')) { + return Response.json({ error: { message: 'Insufficient client scope' } }, { status: 403 }); + } + if (String(url).includes('/items')) { + return Response.json({ error: { message: 'Forbidden' } }, { status: 403 }); + } return Response.json({ tracks: { items: [{ uri: 'spotify:track:1', name: 'Song', type: 'track' }] }, artists: { items: [{ uri: 'spotify:artist:1', name: 'Aphex Twin', type: 'artist' }] } }); }; try { @@ -28,5 +34,7 @@ test('PKCE state is single use and token persists privately; exact artist ranks assert.equal(JSON.parse(readFileSync(join(dir, 'spotify.json'))).refresh_token, 'refresh'); assert.deepEqual(spotifyStatus(), { configured: true, connected: true }); assert.equal((await searchSpotify('aphex twin'))[0].type, 'artist'); + assert.equal((await playlistTool({ action: 'list' })).error, 'spotify_permission_required'); + assert.equal((await playlistTool({ action: 'read', playlistUri: 'spotify:playlist:test' })).error, 'spotify_playlist_access_denied'); } finally { globalThis.fetch = original; rmSync(dir, { recursive: true, force: true }); } });