diff --git a/app/composables/npm/usePackage.ts b/app/composables/npm/usePackage.ts index 7864f1b6f5..02c5703fd0 100644 --- a/app/composables/npm/usePackage.ts +++ b/app/composables/npm/usePackage.ts @@ -1,3 +1,5 @@ +import { normalizeLicense } from '#shared/utils/npm' + /** Number of recent versions to include in initial payload */ const RECENT_VERSIONS_COUNT = 5 @@ -16,13 +18,6 @@ function getTrustLevel(version: PackumentVersion): PublishTrustLevel { return 'none' } -function normalizeLicense(license?: PackumentLicense): string | undefined { - if (!license) return undefined - if (typeof license === 'string') return license - if (typeof license.type === 'string') return license.type - return undefined -} - /** * Transform a full Packument into a slimmed version for client-side use. * Reduces payload size by: diff --git a/server/api/registry/license-change/[...pkg].get.ts b/server/api/registry/license-change/[...pkg].get.ts index e9f831aaa5..10626f9587 100644 --- a/server/api/registry/license-change/[...pkg].get.ts +++ b/server/api/registry/license-change/[...pkg].get.ts @@ -1,3 +1,5 @@ +import { normalizeLicense } from '#shared/utils/npm' + interface LicenseChangeRecord { from: string to: string @@ -42,13 +44,18 @@ export default defineCachedEventHandler( version === 'latest' ? versions.length - 1 : versions.findIndex(v => v.version === version) const previousVersionIndex = currentVersionIndex - 1 - const currentLicense = String(versions[currentVersionIndex]?.license || 'UNKNOWN') - const previousLicense = String(versions[previousVersionIndex]?.license || 'UNKNOWN') - if (currentLicense !== previousLicense) { - change = { - from: previousLicense, - to: currentLicense, + // Skip when there's no real previous version, else we'd diff against a phantom 'UNKNOWN'. + if (currentVersionIndex > 0) { + const currentLicense = normalizeLicense(versions[currentVersionIndex]?.license) ?? 'UNKNOWN' + const previousLicense = + normalizeLicense(versions[previousVersionIndex]?.license) ?? 'UNKNOWN' + + if (currentLicense !== previousLicense) { + change = { + from: previousLicense, + to: currentLicense, + } } } return { change } diff --git a/shared/utils/npm.ts b/shared/utils/npm.ts index bfb8dc7a40..a4be2e00ff 100644 --- a/shared/utils/npm.ts +++ b/shared/utils/npm.ts @@ -1,6 +1,7 @@ import { getLatestVersion } from 'fast-npm-meta' import { createError } from 'h3' import validatePackageName from 'validate-npm-package-name' +import type { PackumentLicense } from '#shared/types/npm-registry' const NPM_USERNAME_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i const NPM_USERNAME_MAX_LENGTH = 50 @@ -62,3 +63,17 @@ export function assertValidUsername(username: string): void { }) } } + +/** + * Normalize a packument `license` field to a plain string. + * The field can be a string or an object with a `type` property. + * + * @param license Raw license value from a packument + * @returns License string, or `undefined` if not present or unrecognized + */ +export function normalizeLicense(license?: PackumentLicense): string | undefined { + if (!license) return undefined + if (typeof license === 'string') return license + if (typeof license.type === 'string') return license.type + return undefined +} diff --git a/test/unit/server/api/registry/license-change/pkg.get.spec.ts b/test/unit/server/api/registry/license-change/pkg.get.spec.ts new file mode 100644 index 0000000000..b9eefc4f07 --- /dev/null +++ b/test/unit/server/api/registry/license-change/pkg.get.spec.ts @@ -0,0 +1,213 @@ +import { describe, expect, it, vi, beforeEach, afterAll } from 'vitest' +import { createError, type H3Event } from 'h3' +import type { Packument, PackumentLicense, PackumentVersion } from '#shared/types/npm-registry' + +const fetchNpmPackageMock = vi.fn() +vi.stubGlobal('fetchNpmPackage', fetchNpmPackageMock) +vi.stubGlobal('defineCachedEventHandler', (fn: Function) => fn) + +let routerParam: string | undefined +let queryParams: Record = {} + +vi.stubGlobal('getRouterParam', (_event: unknown, _name: string) => routerParam) +vi.stubGlobal('getQuery', () => queryParams) +vi.stubGlobal('createError', createError) + +const handler = (await import('#server/api/registry/license-change/[...pkg].get')).default + +function makePackument(opts: { + versions: Record< + string, + Partial> & { license?: PackumentLicense } + > + time: Record +}): Packument { + return { + 'dist-tags': {}, + 'versions': Object.fromEntries( + Object.entries(opts.versions).map(([v, data]) => [v, { version: v, ...data }]), + ), + 'time': opts.time, + } as Packument +} + +const fakeEvent = {} as H3Event + +afterAll(() => { + vi.unstubAllGlobals() +}) + +describe('license-change API', () => { + beforeEach(() => { + vi.clearAllMocks() + routerParam = undefined + queryParams = {} + }) + + it('throws 400 when package name param is missing', async () => { + routerParam = undefined + await expect(handler(fakeEvent)).rejects.toMatchObject({ statusCode: 400 }) + }) + + it('reports no change for a new package with a single version (issue #2720)', async () => { + routerParam = 'vsxtools' + + fetchNpmPackageMock.mockResolvedValue( + makePackument({ + versions: { '0.0.1': { license: 'MIT' } }, + time: { '0.0.1': '2024-01-01T00:00:00Z' }, + }), + ) + + const result = await handler(fakeEvent) + expect(result.change).toBeNull() + }) + + it('reports a change when the license changed between versions', async () => { + routerParam = 'my-pkg' + + fetchNpmPackageMock.mockResolvedValue( + makePackument({ + versions: { + '1.0.0': { license: 'MIT' }, + '2.0.0': { license: 'ISC' }, + }, + time: { + '1.0.0': '2024-01-01T00:00:00Z', + '2.0.0': '2024-06-01T00:00:00Z', + }, + }), + ) + + const result = await handler(fakeEvent) + expect(result.change).toEqual({ from: 'MIT', to: 'ISC' }) + }) + + it('reports no change when the license is unchanged between versions', async () => { + routerParam = 'my-pkg' + + fetchNpmPackageMock.mockResolvedValue( + makePackument({ + versions: { + '1.0.0': { license: 'MIT' }, + '2.0.0': { license: 'MIT' }, + }, + time: { + '1.0.0': '2024-01-01T00:00:00Z', + '2.0.0': '2024-06-01T00:00:00Z', + }, + }), + ) + + const result = await handler(fakeEvent) + expect(result.change).toBeNull() + }) + + it('extracts license string from object format', async () => { + routerParam = 'my-pkg' + + fetchNpmPackageMock.mockResolvedValue( + makePackument({ + versions: { + '1.0.0': { license: { type: 'MIT' } }, + '2.0.0': { license: { type: 'Apache-2.0', url: 'https://example.com' } }, + }, + time: { + '1.0.0': '2024-01-01T00:00:00Z', + '2.0.0': '2024-06-01T00:00:00Z', + }, + }), + ) + + const result = await handler(fakeEvent) + expect(result.change).toEqual({ from: 'MIT', to: 'Apache-2.0' }) + }) + + it('defaults to the latest (chronologically newest) version', async () => { + routerParam = 'my-pkg' + queryParams = {} + + fetchNpmPackageMock.mockResolvedValue( + makePackument({ + versions: { + '1.0.0': { license: 'MIT' }, + '2.0.0': { license: 'MIT' }, + '3.0.0': { license: 'Apache-2.0' }, + }, + time: { + '1.0.0': '2024-01-01T00:00:00Z', + '2.0.0': '2024-06-01T00:00:00Z', + '3.0.0': '2025-01-01T00:00:00Z', + }, + }), + ) + + const result = await handler(fakeEvent) + expect(result.change).toEqual({ from: 'MIT', to: 'Apache-2.0' }) + }) + + it('compares the requested version against its predecessor', async () => { + routerParam = 'my-pkg' + queryParams = { version: '2.0.0' } + + fetchNpmPackageMock.mockResolvedValue( + makePackument({ + versions: { + '1.0.0': { license: 'MIT' }, + '2.0.0': { license: 'ISC' }, + '3.0.0': { license: 'Apache-2.0' }, + }, + time: { + '1.0.0': '2024-01-01T00:00:00Z', + '2.0.0': '2024-06-01T00:00:00Z', + '3.0.0': '2025-01-01T00:00:00Z', + }, + }), + ) + + const result = await handler(fakeEvent) + expect(result.change).toEqual({ from: 'MIT', to: 'ISC' }) + }) + + it('reports no change when the requested version is the oldest', async () => { + routerParam = 'my-pkg' + queryParams = { version: '1.0.0' } + + fetchNpmPackageMock.mockResolvedValue( + makePackument({ + versions: { + '1.0.0': { license: 'MIT' }, + '2.0.0': { license: 'ISC' }, + }, + time: { + '1.0.0': '2024-01-01T00:00:00Z', + '2.0.0': '2024-06-01T00:00:00Z', + }, + }), + ) + + const result = await handler(fakeEvent) + expect(result.change).toBeNull() + }) + + it('reports no change when the requested version is not found', async () => { + routerParam = 'my-pkg' + queryParams = { version: '9.9.9' } + + fetchNpmPackageMock.mockResolvedValue( + makePackument({ + versions: { + '1.0.0': { license: 'MIT' }, + '2.0.0': { license: 'ISC' }, + }, + time: { + '1.0.0': '2024-01-01T00:00:00Z', + '2.0.0': '2024-06-01T00:00:00Z', + }, + }), + ) + + const result = await handler(fakeEvent) + expect(result.change).toBeNull() + }) +})