1
0

fix: harden discogs mappers against malformed payloads, name error classes, assert auth header

This commit is contained in:
2026-08-29 16:24:40 +02:00
parent 3715bb7447
commit 72f3fc8436
2 changed files with 54 additions and 33 deletions

View File

@@ -25,6 +25,7 @@ export class DiscogsError extends Error {
message: string message: string
) { ) {
super(message) super(message)
this.name = new.target.name
} }
} }
export class DiscogsAuthError extends DiscogsError { export class DiscogsAuthError extends DiscogsError {
@@ -52,41 +53,43 @@ export function toYear(year: unknown): number | null {
} }
export function mapSearchResult(r: any): DiscogsReleaseSummary { export function mapSearchResult(r: any): DiscogsReleaseSummary {
const { artist, title } = splitTitle(String(r.title ?? '')) const src = r ?? {}
const { artist, title } = splitTitle(String(src.title ?? ''))
return { return {
id: Number(r.id), id: Number(src.id),
artist, artist,
title, title,
year: toYear(r.year), year: toYear(src.year),
formats: Array.isArray(r.format) ? r.format.map(String) : [], formats: Array.isArray(src.format) ? src.format.filter((f: any) => f != null).map(String) : [],
labels: Array.isArray(r.label) ? r.label.map(String) : [], labels: Array.isArray(src.label) ? src.label.filter((l: any) => l != null).map(String) : [],
country: r.country ?? null, country: src.country ?? null,
catno: r.catno ?? null, catno: src.catno ?? null,
thumbUrl: r.thumb ?? r.cover_image ?? null, thumbUrl: src.thumb ?? src.cover_image ?? null,
} }
} }
export function mapRelease(r: any): DiscogsReleaseFull { export function mapRelease(r: any): DiscogsReleaseFull {
const src = r ?? {}
return { return {
id: Number(r.id), id: Number(src.id),
artist: Array.isArray(r.artists) && r.artists[0] ? String(r.artists[0].name) : '', artist: Array.isArray(src.artists) && src.artists[0] ? String(src.artists[0]?.name ?? '') : '',
title: String(r.title ?? ''), title: String(src.title ?? ''),
year: toYear(r.year), year: toYear(src.year),
formats: Array.isArray(r.formats) ? r.formats.map((f: any) => String(f.name)) : [], formats: Array.isArray(src.formats) ? src.formats.map((f: any) => String(f?.name ?? '')) : [],
labels: Array.isArray(r.labels) ? r.labels.map((l: any) => String(l.name)) : [], labels: Array.isArray(src.labels) ? src.labels.map((l: any) => String(l?.name ?? '')) : [],
country: r.country ?? null, country: src.country ?? null,
catno: Array.isArray(r.labels) && r.labels[0] ? (r.labels[0].catno ?? null) : null, catno: Array.isArray(src.labels) && src.labels[0] ? (src.labels[0]?.catno ?? null) : null,
thumbUrl: r.thumb ?? (r.images?.[0]?.uri ?? null), thumbUrl: src.thumb ?? (src.images?.[0]?.uri ?? null),
genres: Array.isArray(r.genres) ? r.genres.map(String) : [], genres: Array.isArray(src.genres) ? src.genres.filter((g: any) => g != null).map(String) : [],
tracklist: Array.isArray(r.tracklist) tracklist: Array.isArray(src.tracklist)
? r.tracklist ? src.tracklist
.filter((t: any) => t.title) .filter((t: any) => t?.title)
.map((t: any) => ({ position: String(t.position ?? ''), title: String(t.title) })) .map((t: any) => ({ position: String(t?.position ?? ''), title: String(t?.title ?? '') }))
: [], : [],
coverUrl: r.images?.[0]?.uri ?? null, coverUrl: src.images?.[0]?.uri ?? null,
barcodes: Array.isArray(r.identifiers) barcodes: Array.isArray(src.identifiers)
? r.identifiers ? src.identifiers
.filter((i: any) => i.type === 'Barcode' && i.value) .filter((i: any) => i?.type === 'Barcode' && i?.value)
.map((i: any) => String(i.value)) .map((i: any) => String(i.value))
: [], : [],
} }
@@ -125,21 +128,27 @@ export class DiscogsClient {
if (res.status === 401) throw new DiscogsAuthError() if (res.status === 401) throw new DiscogsAuthError()
if (res.status === 429) throw new DiscogsRateLimitError() if (res.status === 429) throw new DiscogsRateLimitError()
if (!res.ok) throw new DiscogsError(res.status, `discogs HTTP ${res.status}`) if (!res.ok) throw new DiscogsError(res.status, `discogs HTTP ${res.status}`)
return res.json() try {
return await res.json()
} catch {
throw new DiscogsError(res.status, 'discogs returned a non-JSON response')
}
} }
return this.queue ? this.queue.run(doFetch) : doFetch() return this.queue ? this.queue.run(doFetch) : doFetch()
} }
async searchByBarcode(barcode: string): Promise<DiscogsReleaseSummary[]> { async searchByBarcode(barcode: string): Promise<DiscogsReleaseSummary[]> {
const body = await this.get('/database/search', { barcode, type: 'release', per_page: '20' }) const body = await this.get('/database/search', { barcode, type: 'release', per_page: '20' })
return (body.results ?? []).map(mapSearchResult) const results: any[] = Array.isArray(body?.results) ? body.results : []
return results.map(mapSearchResult)
} }
async searchByText(query: string, format?: string): Promise<DiscogsReleaseSummary[]> { async searchByText(query: string, format?: string): Promise<DiscogsReleaseSummary[]> {
const params: Record<string, string> = { q: query, type: 'release', per_page: '20' } const params: Record<string, string> = { q: query, type: 'release', per_page: '20' }
if (format) params.format = format if (format) params.format = format
const body = await this.get('/database/search', params) const body = await this.get('/database/search', params)
return (body.results ?? []).map(mapSearchResult) const results: any[] = Array.isArray(body?.results) ? body.results : []
return results.map(mapSearchResult)
} }
async getRelease(id: number): Promise<DiscogsReleaseFull> { async getRelease(id: number): Promise<DiscogsReleaseFull> {

View File

@@ -15,17 +15,19 @@ function jsonResponse(body: unknown, status = 200): Response {
}) })
} }
function stubFetch(routes: (url: string) => Response): typeof fetch { function stubFetch(routes: (url: string, init?: any) => Response): typeof fetch {
return (async (input: any) => routes(String(input))) as typeof fetch return (async (input: any, init?: any) => routes(String(input), init)) as typeof fetch
} }
describe('DiscogsClient', () => { describe('DiscogsClient', () => {
it('searches by barcode with token and maps results', async () => { it('searches by barcode with token and maps results', async () => {
const seen: string[] = [] const seen: string[] = []
const seenAuth: string[] = []
const c = new DiscogsClient( const c = new DiscogsClient(
'testtoken', 'testtoken',
stubFetch((url) => { stubFetch((_url, init) => {
seen.push(url) seen.push(_url)
seenAuth.push(String(new Headers(init?.headers).get('Authorization')))
return jsonResponse(discogsSearchFixture) return jsonResponse(discogsSearchFixture)
}) })
) )
@@ -34,6 +36,7 @@ describe('DiscogsClient', () => {
expect(seen[0]).toContain('barcode=5021592210629') expect(seen[0]).toContain('barcode=5021592210629')
expect(seen[0]).toContain('type=release') expect(seen[0]).toContain('type=release')
expect(seen[0]).toContain('token=testtoken') expect(seen[0]).toContain('token=testtoken')
expect(seenAuth[0]).toBe('Discogs token=testtoken')
expect(results).toHaveLength(2) expect(results).toHaveLength(2)
expect(results[0]).toEqual({ expect(results[0]).toEqual({
id: 1001, id: 1001,
@@ -105,4 +108,13 @@ describe('mappers', () => {
{ position: '2', title: 'Theme de Yoyo' }, { position: '2', title: 'Theme de Yoyo' },
]) ])
}) })
it('does not throw on malformed payloads', () => {
expect(() => mapSearchResult(null)).not.toThrow()
expect(() => mapRelease(undefined)).not.toThrow()
expect(mapRelease({ tracklist: [null, { title: 'X' }] }).tracklist).toEqual([{ position: '', title: 'X' }])
expect(mapSearchResult({ format: [null, 'CD'] }).formats).toEqual(['CD'])
expect(new DiscogsAuthError().name).toBe('DiscogsAuthError')
expect(new DiscogsRateLimitError().name).toBe('DiscogsRateLimitError')
})
}) })