Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 3 additions & 9 deletions app/composables/npm/usePackage.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,9 @@
import { getTrustLevel, getTrustStatus } from 'packumeta'
import { normalizePackageLicense } from '#shared/utils/npm'

/** Number of recent versions to include in initial payload */
const RECENT_VERSIONS_COUNT = 5

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:
Expand Down Expand Up @@ -65,7 +59,7 @@ export function transformPackument(
for (const v of includedVersions) {
const version = pkg.versions[v]
if (version) {
const versionLicense = normalizeLicense(version.license)
const versionLicense = normalizePackageLicense(version.license)
if (version.version === requestedVersion) {
// Strip readme from each version, extract install scripts info
const { readme: _readme, scripts, ...slimVersion } = version
Expand Down Expand Up @@ -99,7 +93,7 @@ export function transformPackument(
}

// Normalize license field
const license = normalizeLicense(requestedVersion ? versionData?.license : pkg.license)
const license = normalizePackageLicense(requestedVersion ? versionData?.license : pkg.license)

// Extract storybook field from the requested version (custom package.json field)
const requestedPkgVersion = requestedVersion ? pkg.versions[requestedVersion] : null
Expand Down
6 changes: 2 additions & 4 deletions app/composables/usePackageComparison.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { normalizePackageLicense } from '#shared/utils/npm'
import { getDependencyCount } from '~/utils/npm/dependency-count'

/** Special identifier for the "What Would James Do?" comparison column */
Expand Down Expand Up @@ -193,10 +194,7 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter<string[]>) {
severity: vulnsSeverity,
},
metadata: {
license:
typeof pkgData.license === 'object' && 'type' in pkgData.license
? pkgData.license.type
: pkgData.license,
license: normalizePackageLicense(pkgData.license),
// Use version-specific publish time, NOT time.modified (which can be
// updated by metadata changes like maintainer additions)
lastUpdated: pkgData.time?.[latestVersion],
Expand Down
4 changes: 2 additions & 2 deletions server/api/registry/badge/[type]/[...pkg].get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { createError, getRouterParam, getQuery, setHeader } from 'h3'
import { PackageRouteParamsSchema } from '#shared/schemas/package'
import { CACHE_MAX_AGE_ONE_HOUR, ERROR_NPM_FETCH_FAILED } from '#shared/utils/constants'
import { fetchNpmPackage } from '#server/utils/npm'
import { assertValidPackageName } from '#shared/utils/npm'
import { assertValidPackageName, normalizePackageLicense } from '#shared/utils/npm'
import { fetchPackageWithTypesAndFiles } from '#server/utils/file-tree'
import { handleApiError } from '#server/utils/error-handler'

Expand Down Expand Up @@ -528,7 +528,7 @@ const badgeStrategies = {
'license': async (pkgData: globalThis.Packument) => {
const latest = getLatestVersion(pkgData)
const versionData = latest ? pkgData.versions?.[latest] : undefined
const value = versionData?.license ?? 'unknown'
const value = normalizePackageLicense(versionData?.license) ?? 'unknown'
return { label: 'license', value, color: COLORS.green }
},

Expand Down
12 changes: 10 additions & 2 deletions server/api/registry/license-change/[...pkg].get.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { normalizePackageLicense } from '#shared/utils/npm'

interface LicenseChangeRecord {
from: string
to: string
Expand Down Expand Up @@ -42,8 +44,14 @@ 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 (previousVersionIndex < 0) {
return { change }
}

const currentLicense =
normalizePackageLicense(versions[currentVersionIndex]?.license) ?? 'UNKNOWN'
const previousLicense =
normalizePackageLicense(versions[previousVersionIndex]?.license) ?? 'UNKNOWN'

if (currentLicense !== previousLicense) {
change = {
Expand Down
11 changes: 3 additions & 8 deletions server/api/registry/package-meta/[...pkg].get.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { normalizePackageLicense } from '#shared/utils/npm'

/**
* Returns lightweight package metadata for search results.
*
Expand Down Expand Up @@ -66,14 +68,7 @@ export default defineCachedEventHandler(
author = typeof a === 'string' ? { name: a } : { name: a.name, email: a.email, url: a.url }
}

// Normalize license to a string
// TODO: @npm/types types license as string, but some old packages use
// the deprecated { type, url } object format
const license = packument.license
? typeof packument.license === 'string'
? packument.license
: (packument.license as { type: string }).type
: undefined
const license = normalizePackageLicense(packument.license)

return {
name: packument.name,
Expand Down
7 changes: 2 additions & 5 deletions server/api/registry/timeline/[...pkg].get.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { normalizePackageLicense } from '#shared/utils/npm'
import { hasBuiltInTypes } from '~~/shared/utils/package-analysis'

const DEFAULT_LIMIT = 25
Expand Down Expand Up @@ -68,15 +69,11 @@ export default defineCachedEventHandler(
.filter(v => packument.time[v])
.map(v => {
const version = packument.versions[v]!
let license = version.license
if (license && typeof license === 'object' && 'type' in license) {
license = (license as { type: string }).type
}

return {
version: v,
time: packument.time[v]!,
license: typeof license === 'string' ? license : undefined,
license: normalizePackageLicense(version.license),
type: typeof version.type === 'string' ? version.type : undefined,
hasTypes: hasBuiltInTypes(version) || undefined,
hasTrustedPublisher: version._npmUser?.trustedPublisher ? true : undefined,
Expand Down
8 changes: 8 additions & 0 deletions shared/utils/npm.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -16,6 +17,13 @@ export function encodePackageName(name: string): string {
return encodeURIComponent(name)
}

export function normalizePackageLicense(license?: PackumentLicense): string | undefined {
if (!license) return undefined
if (typeof license === 'string') return license
if (typeof license.type === 'string') return license.type
return undefined
}

/**
* Fetch the latest version of a package using fast-npm-meta API.
* This is a lightweight alternative to fetching the full packument.
Expand Down
61 changes: 61 additions & 0 deletions test/unit/server/api/registry/badge/pkg.get.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import type { H3Event } from 'h3'
import type { Packument } from '#shared/types/npm-registry'
import { parsePackageParams } from '#server/utils/parse-package-params'

const fetchNpmPackageMock = vi.hoisted(() => vi.fn())
const getRouterParamMock = vi.hoisted(() => vi.fn())
const getQueryMock = vi.hoisted(() => vi.fn())
const setHeaderMock = vi.hoisted(() => vi.fn())

vi.mock('#server/utils/npm', () => ({
fetchNpmPackage: fetchNpmPackageMock,
}))

vi.mock('h3', () => ({
createError: vi.fn((options: unknown) => options),
getRouterParam: getRouterParamMock,
getQuery: getQueryMock,
setHeader: setHeaderMock,
}))

vi.stubGlobal('defineCachedEventHandler', (fn: Function) => fn)
vi.stubGlobal('parsePackageParams', parsePackageParams)

const handler = (await import('#server/api/registry/badge/[type]/[...pkg].get')).default

const fakeEvent = {} as H3Event

afterAll(() => {
vi.unstubAllGlobals()
})

describe('badge API', () => {
beforeEach(() => {
vi.clearAllMocks()
getQueryMock.mockReturnValue({})
getRouterParamMock.mockImplementation((_event: unknown, name: string) => {
if (name === 'type') return 'license'
if (name === 'pkg') return 'my-pkg'
return undefined
})
})

it('normalizes object-shaped licenses before rendering SVG', async () => {
fetchNpmPackageMock.mockResolvedValue({
'_id': 'my-pkg',
'_rev': '1',
'name': 'my-pkg',
'dist-tags': { latest: '1.0.0' },
'versions': {
'1.0.0': { version: '1.0.0', license: { type: 'MIT' } },
},
'time': {},
} as unknown as Packument)

const svg = await handler(fakeEvent)

expect(svg).toContain('>MIT<')
expect(svg).not.toContain('[object Object]')
})
})
102 changes: 102 additions & 0 deletions test/unit/server/api/registry/license-change/pkg.get.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { createError, type H3Event } from 'h3'
import type { Packument, PackumentVersion } from '#shared/types/npm-registry'

const fetchNpmPackageMock = vi.fn()
vi.stubGlobal('fetchNpmPackage', fetchNpmPackageMock)
vi.stubGlobal('defineCachedEventHandler', (fn: Function) => fn)
vi.stubGlobal('createError', createError)

let routerParam: string | undefined
let queryParams: Record<string, string | number> = {}

vi.stubGlobal('getRouterParam', (_event: unknown, _name: string) => routerParam)
vi.stubGlobal('getQuery', () => queryParams)

const handler = (await import('#server/api/registry/license-change/[...pkg].get')).default

function makePackument(opts: {
versions: Record<string, Partial<PackumentVersion>>
time: Record<string, string>
}): 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 = 'my-pkg'
queryParams = {}
})

it('does not report a license change for the first package version', async () => {
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',
},
}),
)

await expect(handler(fakeEvent)).resolves.toEqual({ change: null })
})

it('reports a license change when a previous version exists', async () => {
queryParams = { version: '2.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',
},
}),
)

await expect(handler(fakeEvent)).resolves.toEqual({
change: {
from: 'MIT',
to: 'ISC',
},
})
})

it('normalizes license object types before comparing versions', async () => {
queryParams = { version: '2.0.0' }
fetchNpmPackageMock.mockResolvedValue(
makePackument({
versions: {
'1.0.0': { license: 'MIT' },
'2.0.0': { license: { type: 'MIT' } as never },
},
time: {
'1.0.0': '2024-01-01T00:00:00Z',
'2.0.0': '2024-06-01T00:00:00Z',
},
}),
)

await expect(handler(fakeEvent)).resolves.toEqual({ change: null })
})
})
Comment thread
gameroman marked this conversation as resolved.
25 changes: 25 additions & 0 deletions test/unit/shared/utils/npm.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { normalizePackageLicense } from '#shared/utils/npm'
import type { PackumentLicense } from '#shared/types/npm-registry'

describe('normalizePackageLicense', () => {
it('returns string licenses unchanged', () => {
expect(normalizePackageLicense('MIT')).toBe('MIT')
})

it('extracts type from legacy object licenses', () => {
expect(normalizePackageLicense({ type: 'Apache-2.0', url: 'https://example.com' })).toBe(
'Apache-2.0',
)
})

it('returns undefined for empty licenses', () => {
expect(normalizePackageLicense(undefined)).toBeUndefined()
expect(normalizePackageLicense('')).toBeUndefined()
})

it('returns undefined for malformed object licenses', () => {
expect(normalizePackageLicense({} as PackumentLicense)).toBeUndefined()
expect(normalizePackageLicense({ type: 123 } as unknown as PackumentLicense)).toBeUndefined()
})
})
Loading