Distinguish Spotify playlist restrictions from missing OAuth scopes
This commit is contained in:
+11
-1
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 }); }
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user