diff --git a/.github/workflows/supply-chain-check.yml b/.github/workflows/supply-chain-check.yml new file mode 100644 index 0000000..3b287ad --- /dev/null +++ b/.github/workflows/supply-chain-check.yml @@ -0,0 +1,78 @@ +name: Supply Chain Security Check + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + issues: read + id-token: write + +jobs: + supply-chain-check: + if: ${{ !github.event.pull_request.head.repo.fork }} + runs-on: ubuntu-slim + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Use Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: '20.x' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Get base branch package.json + id: base-pkg + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + git show "$BASE_SHA":package.json > /tmp/base-package.json + + - name: Run supply chain security check + id: check + run: | + npx ts-node scripts/supply-chain-check-runner.ts /tmp/base-package.json > /tmp/supply-chain-report.md + + - name: Claude supply chain analysis + uses: anthropics/claude-code-action@88c168b39e7e64da0286d812b6e9fbebb6708185 # v1.0.82 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + use_sticky_comment: true + direct_prompt: | + You are a supply chain security analyst. Your job is to analyze dependency changes + in this PR for supply chain attack risks and known vulnerabilities. + + ## Step 1: Read the automated report + Read the supply chain security check report at /tmp/supply-chain-report.md. + This contains dependency changes, risk signals, and npm audit results. + + ## Step 2: Web research for each changed/added package + For each added or updated dependency found in the report, use WebSearch to research: + - " npm security vulnerability" (recent CVEs or advisories) + - " npm supply chain attack" (known compromise incidents) + - " npm malware typosquatting" (typosquatting or impersonation) + Search in English. Focus on results from the last 12 months. + + ## Step 3: Post a PR comment in English + Write a concise PR comment that includes: + 1. A summary header with ✅ (all clear) or ⚠️ (issues found) + 2. A table of dependency changes (added/updated/removed) if any + 3. Automated risk signals from the report + 4. **Web research findings** — for each package, summarize what you found + (or note "No recent security issues found" if clean) + 5. npm audit vulnerability findings if any + 6. An actionable recommendation section + + Keep the comment concise and focused on actionable insights. + Use markdown formatting for readability. + IMPORTANT: Your entire analysis and comment MUST be in English. + allowed_bots: 'dependabot[bot],claude[bot]' + claude_args: '--allowedTools Read,WebSearch,WebFetch,Bash(cat:*)' diff --git a/package.json b/package.json index 2001620..fbd21bd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@urugus/slack-cli", - "version": "0.20.21", + "version": "0.21.0", "description": "A command-line tool for sending messages to Slack", "main": "dist/index.js", "bin": { diff --git a/scripts/supply-chain-check-runner.ts b/scripts/supply-chain-check-runner.ts new file mode 100644 index 0000000..7802724 --- /dev/null +++ b/scripts/supply-chain-check-runner.ts @@ -0,0 +1,78 @@ +/** + * CI runner script for supply chain security checks. + * Invoked by the GitHub Actions workflow to analyze dependency changes, + * fetch package metadata, run npm audit, and output a markdown report. + * + * Usage: npx ts-node scripts/supply-chain-check-runner.ts + * base-package-json-path: Path to the base branch's package.json file + * + * Outputs the markdown report to stdout. + */ + +import * as fs from 'node:fs'; +import { + analyzePackageRisk, + type DependencyChange, + fetchPackageMetadata, + findDependencyChanges, + generateReport, + runNpmAudit, +} from './supply-chain-check'; + +async function main() { + const basePackageJsonPath = process.argv[2]; + if (!basePackageJsonPath) { + console.error('Usage: supply-chain-check-runner.ts '); + process.exit(1); + } + + const basePackage = JSON.parse(fs.readFileSync(basePackageJsonPath, 'utf-8')); + const headPackage = JSON.parse(fs.readFileSync('package.json', 'utf-8')); + + // Find all dependency changes (production + dev) + const prodChanges = findDependencyChanges(basePackage.dependencies, headPackage.dependencies); + const devChanges = findDependencyChanges( + basePackage.devDependencies, + headPackage.devDependencies + ); + const allChanges = [...prodChanges, ...devChanges]; + + // Analyze risk for added and updated packages + const packagesToCheck = allChanges.filter( + (c): c is DependencyChange & { newVersion: string } => + (c.type === 'added' || c.type === 'updated') && c.newVersion !== undefined + ); + + const riskResults: { pkg: string; risks: ReturnType }[] = []; + + for (const pkg of packagesToCheck) { + try { + const metadata = await fetchPackageMetadata(pkg.name, pkg.newVersion); + const risks = analyzePackageRisk(metadata); + riskResults.push({ pkg: pkg.name, risks }); + } catch (error) { + riskResults.push({ + pkg: pkg.name, + risks: [ + { + type: 'metadata-fetch-failed' as const, + severity: 'high' as const, + message: `Failed to fetch package metadata: ${error instanceof Error ? error.message : String(error)}`, + }, + ], + }); + } + } + + // Run npm audit + const auditResult = await runNpmAudit(); + + // Generate report + const report = generateReport(allChanges, riskResults, auditResult); + console.log(report); +} + +main().catch((error) => { + console.error('Supply chain check failed:', error); + process.exit(1); +}); diff --git a/scripts/supply-chain-check.ts b/scripts/supply-chain-check.ts new file mode 100644 index 0000000..7cb0b12 --- /dev/null +++ b/scripts/supply-chain-check.ts @@ -0,0 +1,296 @@ +export interface DependencyChange { + name: string; + type: 'added' | 'updated' | 'removed'; + oldVersion?: string; + newVersion?: string; +} + +export interface PackageMetadata { + name: string; + version: string; + publishedAt: string; + maintainerCount: number; + weeklyDownloads: number; + hasTypes: boolean; + license?: string; + repositoryUrl?: string; + description?: string; +} + +export interface RiskSignal { + type: + | 'very-new-version' + | 'low-downloads' + | 'single-maintainer' + | 'no-repository' + | 'no-license' + | 'metadata-fetch-failed'; + severity: 'high' | 'medium' | 'low'; + message: string; +} + +export interface NpmAuditResult { + vulnerabilities: { + total: number; + critical?: number; + high?: number; + moderate?: number; + low?: number; + }; + advisories?: { + severity: string; + title: string; + module_name: string; + url: string; + }[]; +} + +const DOWNLOAD_THRESHOLD_LOW = 100; +const NEW_VERSION_DAYS = 7; + +export function findDependencyChanges( + baseDeps: Record | undefined, + headDeps: Record | undefined +): DependencyChange[] { + const base = baseDeps ?? {}; + const head = headDeps ?? {}; + const changes: DependencyChange[] = []; + + for (const [name, version] of Object.entries(head)) { + if (!(name in base)) { + changes.push({ name, type: 'added', newVersion: version }); + } else if (base[name] !== version) { + changes.push({ + name, + type: 'updated', + oldVersion: base[name], + newVersion: version, + }); + } + } + + for (const [name, version] of Object.entries(base)) { + if (!(name in head)) { + changes.push({ name, type: 'removed', oldVersion: version }); + } + } + + return changes; +} + +export function analyzePackageRisk(metadata: PackageMetadata): RiskSignal[] { + const risks: RiskSignal[] = []; + + const publishedTime = new Date(metadata.publishedAt).getTime(); + if (!Number.isNaN(publishedTime)) { + const diffMs = Date.now() - publishedTime; + if (diffMs >= 0) { + const daysSincePublish = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + if (daysSincePublish <= NEW_VERSION_DAYS) { + risks.push({ + type: 'very-new-version', + severity: 'high', + message: `Package version published ${daysSincePublish} days ago`, + }); + } + } + } + + if (metadata.weeklyDownloads < DOWNLOAD_THRESHOLD_LOW) { + risks.push({ + type: 'low-downloads', + severity: 'high', + message: `Very low weekly downloads (${metadata.weeklyDownloads})`, + }); + } + + if (metadata.maintainerCount === 1) { + risks.push({ + type: 'single-maintainer', + severity: 'medium', + message: 'Package has only a single maintainer', + }); + } + + if (!metadata.repositoryUrl) { + risks.push({ + type: 'no-repository', + severity: 'medium', + message: 'No source repository URL found', + }); + } + + if (!metadata.license) { + risks.push({ + type: 'no-license', + severity: 'medium', + message: 'No license specified', + }); + } + + return risks; +} + +export function generateReport( + changes: DependencyChange[], + riskResults: { pkg: string; risks: RiskSignal[] }[], + auditResult: NpmAuditResult +): string { + const hasRisks = riskResults.some((r) => r.risks.length > 0); + const hasVulnerabilities = auditResult.vulnerabilities.total > 0; + const statusIcon = hasRisks || hasVulnerabilities ? '⚠️' : '✅'; + + const lines: string[] = []; + lines.push(`## ${statusIcon} Supply Chain Security Check`); + lines.push(''); + + // Dependency changes section + lines.push('### Dependency Changes'); + lines.push(''); + if (changes.length === 0) { + lines.push('No dependency changes detected in this PR.'); + } else { + lines.push('| Package | Change | Version |'); + lines.push('|---------|--------|---------|'); + for (const change of changes) { + const version = + change.type === 'removed' + ? `~~${change.oldVersion}~~` + : change.type === 'updated' + ? `${change.oldVersion} → ${change.newVersion}` + : change.newVersion; + lines.push(`| ${change.name} | ${change.type} | ${version} |`); + } + } + lines.push(''); + + // Risk signals section + if (riskResults.some((r) => r.risks.length > 0)) { + lines.push('### Risk Signals'); + lines.push(''); + for (const result of riskResults) { + if (result.risks.length === 0) continue; + lines.push(`**${result.pkg}:**`); + for (const risk of result.risks) { + const icon = risk.severity === 'high' ? '🔴' : '🟡'; + lines.push(`- ${icon} \`${risk.type}\`: ${risk.message}`); + } + lines.push(''); + } + } + + // npm audit section + lines.push('### npm audit'); + lines.push(''); + if (auditResult.vulnerabilities.total === 0) { + lines.push('No known vulnerabilities found.'); + } else { + const v = auditResult.vulnerabilities; + const parts: string[] = []; + if (v.critical) parts.push(`**${v.critical} critical**`); + if (v.high) parts.push(`**${v.high} high**`); + if (v.moderate) parts.push(`${v.moderate} moderate`); + if (v.low) parts.push(`${v.low} low`); + lines.push(`Found ${v.total} vulnerabilities: ${parts.join(', ')}`); + lines.push(''); + if (auditResult.advisories?.length) { + lines.push('| Severity | Package | Title | Link |'); + lines.push('|----------|---------|-------|------|'); + for (const advisory of auditResult.advisories) { + lines.push( + `| ${advisory.severity} | ${advisory.module_name} | ${advisory.title} | [Details](${advisory.url}) |` + ); + } + } + } + lines.push(''); + + return lines.join('\n'); +} + +export function parseNpmAuditJson(json: string): NpmAuditResult | null { + try { + const result = JSON.parse(json); + const vuln = result.metadata?.vulnerabilities ?? {}; + return { + vulnerabilities: { + total: (vuln.critical ?? 0) + (vuln.high ?? 0) + (vuln.moderate ?? 0) + (vuln.low ?? 0), + critical: vuln.critical, + high: vuln.high, + moderate: vuln.moderate, + low: vuln.low, + }, + advisories: Object.values(result.advisories ?? {}).map((a: Record) => ({ + severity: a.severity as string, + title: a.title as string, + module_name: a.module_name as string, + url: a.url as string, + })), + }; + } catch { + return null; + } +} + +export async function fetchPackageMetadata( + packageName: string, + version: string +): Promise { + const registryUrl = `https://registry.npmjs.org/${encodeURIComponent(packageName)}`; + const response = await fetch(registryUrl); + if (!response.ok) { + throw new Error(`Failed to fetch metadata for ${packageName}: ${response.status}`); + } + const data = await response.json(); + + const versionData = data.versions?.[version] ?? {}; + const timeData = data.time ?? {}; + + const downloadsUrl = `https://api.npmjs.org/downloads/point/last-week/${encodeURIComponent(packageName)}`; + let weeklyDownloads = 0; + try { + const dlResponse = await fetch(downloadsUrl); + if (dlResponse.ok) { + const dlData = await dlResponse.json(); + weeklyDownloads = dlData.downloads ?? 0; + } + } catch { + // Ignore download count fetch failures + } + + return { + name: packageName, + version, + publishedAt: timeData[version] ?? timeData.created ?? new Date().toISOString(), + maintainerCount: (data.maintainers ?? []).length, + weeklyDownloads, + hasTypes: !!versionData.types || !!versionData.typings, + license: versionData.license ?? data.license, + repositoryUrl: versionData.repository?.url ?? data.repository?.url, + description: data.description, + }; +} + +export async function runNpmAudit(): Promise { + const { execSync } = await import('node:child_process'); + try { + const output = execSync('npm audit --json 2>/dev/null', { + encoding: 'utf-8', + timeout: 60000, + }); + const parsed = parseNpmAuditJson(output); + if (parsed) { + return parsed; + } + } catch (error: unknown) { + // npm audit exits with non-zero when vulnerabilities are found + const err = error as { stdout?: string }; + if (err.stdout) { + const parsed = parseNpmAuditJson(err.stdout); + if (parsed) { + return parsed; + } + } + } + return { vulnerabilities: { total: 0 } }; +} diff --git a/tests/scripts/supply-chain-check.test.ts b/tests/scripts/supply-chain-check.test.ts new file mode 100644 index 0000000..155f228 --- /dev/null +++ b/tests/scripts/supply-chain-check.test.ts @@ -0,0 +1,470 @@ +import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from 'vitest'; +import { + analyzePackageRisk, + type DependencyChange, + fetchPackageMetadata, + findDependencyChanges, + generateReport, + type NpmAuditResult, + type PackageMetadata, + parseNpmAuditJson, + type RiskSignal, +} from '../../scripts/supply-chain-check'; + +describe('supply-chain-check', () => { + describe('findDependencyChanges', () => { + it('detects newly added dependencies', () => { + const baseDeps = { chalk: '5.6.2' }; + const headDeps = { chalk: '5.6.2', lodash: '4.17.21' }; + const changes = findDependencyChanges(baseDeps, headDeps); + + expect(changes).toEqual([{ name: 'lodash', type: 'added', newVersion: '4.17.21' }]); + }); + + it('detects updated dependencies', () => { + const baseDeps = { chalk: '5.6.2' }; + const headDeps = { chalk: '5.7.0' }; + const changes = findDependencyChanges(baseDeps, headDeps); + + expect(changes).toEqual([ + { + name: 'chalk', + type: 'updated', + oldVersion: '5.6.2', + newVersion: '5.7.0', + }, + ]); + }); + + it('detects removed dependencies', () => { + const baseDeps = { chalk: '5.6.2', lodash: '4.17.21' }; + const headDeps = { chalk: '5.6.2' }; + const changes = findDependencyChanges(baseDeps, headDeps); + + expect(changes).toEqual([{ name: 'lodash', type: 'removed', oldVersion: '4.17.21' }]); + }); + + it('handles empty base dependencies', () => { + const baseDeps = {}; + const headDeps = { chalk: '5.6.2' }; + const changes = findDependencyChanges(baseDeps, headDeps); + + expect(changes).toEqual([{ name: 'chalk', type: 'added', newVersion: '5.6.2' }]); + }); + + it('handles undefined dependencies', () => { + const changes = findDependencyChanges(undefined, undefined); + expect(changes).toEqual([]); + }); + + it('returns empty array when no changes', () => { + const deps = { chalk: '5.6.2' }; + const changes = findDependencyChanges(deps, deps); + expect(changes).toEqual([]); + }); + }); + + describe('analyzePackageRisk', () => { + const baseMetadata: PackageMetadata = { + name: 'chalk', + version: '5.6.2', + publishedAt: '2023-01-15T00:00:00.000Z', + maintainerCount: 3, + weeklyDownloads: 50_000_000, + hasTypes: true, + license: 'MIT', + repositoryUrl: 'https://github.com/chalk/chalk', + description: 'Terminal string styling done right', + }; + + it('returns no risks for well-established packages', () => { + const risks = analyzePackageRisk(baseMetadata); + expect(risks).toEqual([]); + }); + + it('flags packages published within last 7 days', () => { + const recentDate = new Date(); + recentDate.setDate(recentDate.getDate() - 3); + const metadata: PackageMetadata = { + ...baseMetadata, + publishedAt: recentDate.toISOString(), + }; + + const risks = analyzePackageRisk(metadata); + expect(risks).toContainEqual(expect.objectContaining({ type: 'very-new-version' })); + }); + + it('flags packages with very low download counts', () => { + const metadata: PackageMetadata = { + ...baseMetadata, + weeklyDownloads: 50, + }; + + const risks = analyzePackageRisk(metadata); + expect(risks).toContainEqual(expect.objectContaining({ type: 'low-downloads' })); + }); + + it('flags packages with single maintainer', () => { + const metadata: PackageMetadata = { + ...baseMetadata, + maintainerCount: 1, + }; + + const risks = analyzePackageRisk(metadata); + expect(risks).toContainEqual(expect.objectContaining({ type: 'single-maintainer' })); + }); + + it('flags packages without repository URL', () => { + const metadata: PackageMetadata = { + ...baseMetadata, + repositoryUrl: undefined, + }; + + const risks = analyzePackageRisk(metadata); + expect(risks).toContainEqual(expect.objectContaining({ type: 'no-repository' })); + }); + + it('flags packages without license', () => { + const metadata: PackageMetadata = { + ...baseMetadata, + license: undefined, + }; + + const risks = analyzePackageRisk(metadata); + expect(risks).toContainEqual(expect.objectContaining({ type: 'no-license' })); + }); + + it('does not flag very-new-version for invalid publishedAt', () => { + const metadata: PackageMetadata = { + ...baseMetadata, + publishedAt: 'invalid-date', + }; + + const risks = analyzePackageRisk(metadata); + expect(risks).not.toContainEqual(expect.objectContaining({ type: 'very-new-version' })); + }); + + it('does not flag very-new-version for future publishedAt', () => { + const futureDate = new Date(); + futureDate.setFullYear(futureDate.getFullYear() + 1); + const metadata: PackageMetadata = { + ...baseMetadata, + publishedAt: futureDate.toISOString(), + }; + + const risks = analyzePackageRisk(metadata); + expect(risks).not.toContainEqual(expect.objectContaining({ type: 'very-new-version' })); + }); + }); + + describe('generateReport', () => { + it('generates report with no issues found', () => { + const report = generateReport([], [], { + vulnerabilities: { total: 0 }, + }); + + expect(report).toContain('Supply Chain Security Check'); + expect(report).toContain('No dependency changes detected'); + expect(report).toContain('No known vulnerabilities found'); + }); + + it('generates report with added dependencies and no risks', () => { + const changes: DependencyChange[] = [{ name: 'chalk', type: 'added', newVersion: '5.6.2' }]; + const riskResults: { pkg: string; risks: RiskSignal[] }[] = [{ pkg: 'chalk', risks: [] }]; + + const report = generateReport(changes, riskResults, { + vulnerabilities: { total: 0 }, + }); + + expect(report).toContain('chalk'); + expect(report).toContain('added'); + expect(report).toContain('5.6.2'); + }); + + it('generates report with vulnerabilities', () => { + const auditResult: NpmAuditResult = { + vulnerabilities: { + total: 2, + critical: 1, + high: 1, + }, + advisories: [ + { + severity: 'critical', + title: 'Prototype Pollution', + module_name: 'lodash', + url: 'https://npmjs.com/advisories/1234', + }, + ], + }; + + const report = generateReport([], [], auditResult); + + expect(report).toContain('2 vulnerabilities'); + expect(report).toContain('critical'); + expect(report).toContain('Prototype Pollution'); + }); + + it('generates report with risk signals', () => { + const changes: DependencyChange[] = [ + { name: 'suspicious-pkg', type: 'added', newVersion: '0.0.1' }, + ]; + const riskResults: { pkg: string; risks: RiskSignal[] }[] = [ + { + pkg: 'suspicious-pkg', + risks: [ + { + type: 'very-new-version', + severity: 'high', + message: 'Package version published 2 days ago', + }, + { + type: 'low-downloads', + severity: 'high', + message: 'Very low weekly downloads (10)', + }, + ], + }, + ]; + + const report = generateReport(changes, riskResults, { + vulnerabilities: { total: 0 }, + }); + + expect(report).toContain('suspicious-pkg'); + expect(report).toContain('Risk Signals'); + expect(report).toContain('very-new-version'); + expect(report).toContain('low-downloads'); + }); + + it('includes summary status with warning when risks found', () => { + const riskResults: { pkg: string; risks: RiskSignal[] }[] = [ + { + pkg: 'risky-pkg', + risks: [ + { + type: 'low-downloads', + severity: 'high', + message: 'Very low weekly downloads (5)', + }, + ], + }, + ]; + + const report = generateReport( + [{ name: 'risky-pkg', type: 'added', newVersion: '1.0.0' }], + riskResults, + { vulnerabilities: { total: 0 } } + ); + + expect(report).toContain('⚠️'); + }); + + it('includes summary status with check when all clear', () => { + const report = generateReport([], [], { + vulnerabilities: { total: 0 }, + }); + + expect(report).toContain('✅'); + }); + + it('generates report with metadata-fetch-failed risk signal', () => { + const changes: DependencyChange[] = [ + { name: 'unknown-pkg', type: 'added', newVersion: '1.0.0' }, + ]; + const riskResults: { pkg: string; risks: RiskSignal[] }[] = [ + { + pkg: 'unknown-pkg', + risks: [ + { + type: 'metadata-fetch-failed', + severity: 'high', + message: 'Failed to fetch package metadata: 404', + }, + ], + }, + ]; + + const report = generateReport(changes, riskResults, { + vulnerabilities: { total: 0 }, + }); + + expect(report).toContain('metadata-fetch-failed'); + expect(report).toContain('⚠️'); + }); + }); + + describe('parseNpmAuditJson', () => { + it('parses valid npm audit JSON with vulnerabilities', () => { + const json = JSON.stringify({ + metadata: { + vulnerabilities: { critical: 1, high: 2, moderate: 0, low: 1 }, + }, + advisories: { + '1234': { + severity: 'critical', + title: 'RCE in foo', + module_name: 'foo', + url: 'https://npmjs.com/advisories/1234', + }, + }, + }); + + const result = parseNpmAuditJson(json); + expect(result).not.toBeNull(); + expect(result?.vulnerabilities.total).toBe(4); + expect(result?.vulnerabilities.critical).toBe(1); + expect(result?.vulnerabilities.high).toBe(2); + expect(result?.advisories).toHaveLength(1); + expect(result?.advisories?.[0].title).toBe('RCE in foo'); + }); + + it('parses valid npm audit JSON with no vulnerabilities', () => { + const json = JSON.stringify({ + metadata: { + vulnerabilities: { critical: 0, high: 0, moderate: 0, low: 0 }, + }, + advisories: {}, + }); + + const result = parseNpmAuditJson(json); + expect(result).not.toBeNull(); + expect(result?.vulnerabilities.total).toBe(0); + }); + + it('returns null for invalid JSON', () => { + const result = parseNpmAuditJson('not valid json'); + expect(result).toBeNull(); + }); + + it('returns null for empty string', () => { + const result = parseNpmAuditJson(''); + expect(result).toBeNull(); + }); + + it('handles missing metadata gracefully', () => { + const json = JSON.stringify({}); + const result = parseNpmAuditJson(json); + expect(result).not.toBeNull(); + expect(result?.vulnerabilities.total).toBe(0); + }); + }); + + describe('fetchPackageMetadata', () => { + let fetchSpy: MockInstance; + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('fetches and parses registry data correctly', async () => { + const registryResponse = { + name: 'chalk', + description: 'Terminal string styling', + maintainers: [{ name: 'sindresorhus' }, { name: 'qix' }], + license: 'MIT', + repository: { url: 'https://github.com/chalk/chalk' }, + time: { '5.6.2': '2023-06-01T00:00:00.000Z' }, + versions: { + '5.6.2': { + license: 'MIT', + repository: { url: 'https://github.com/chalk/chalk' }, + types: './index.d.ts', + }, + }, + }; + + const downloadsResponse = { downloads: 50000000 }; + + fetchSpy.mockImplementation((url: string | URL | Request) => { + const urlStr = typeof url === 'string' ? url : url.toString(); + if (urlStr.includes('api.npmjs.org/downloads')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(downloadsResponse), + } as Response); + } + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(registryResponse), + } as Response); + }); + + const metadata = await fetchPackageMetadata('chalk', '5.6.2'); + + expect(metadata.name).toBe('chalk'); + expect(metadata.version).toBe('5.6.2'); + expect(metadata.maintainerCount).toBe(2); + expect(metadata.weeklyDownloads).toBe(50000000); + expect(metadata.hasTypes).toBe(true); + expect(metadata.license).toBe('MIT'); + expect(metadata.repositoryUrl).toBe('https://github.com/chalk/chalk'); + expect(metadata.publishedAt).toBe('2023-06-01T00:00:00.000Z'); + }); + + it('throws error for non-OK registry response', async () => { + fetchSpy.mockResolvedValue({ + ok: false, + status: 404, + } as Response); + + await expect(fetchPackageMetadata('nonexistent-pkg', '1.0.0')).rejects.toThrow( + 'Failed to fetch metadata for nonexistent-pkg: 404' + ); + }); + + it('handles download API failure gracefully', async () => { + const registryResponse = { + name: 'test-pkg', + maintainers: [{ name: 'user1' }], + time: { '1.0.0': '2023-01-01T00:00:00.000Z' }, + versions: { '1.0.0': {} }, + }; + + fetchSpy.mockImplementation((url: string | URL | Request) => { + const urlStr = typeof url === 'string' ? url : url.toString(); + if (urlStr.includes('api.npmjs.org/downloads')) { + return Promise.reject(new Error('Network error')); + } + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(registryResponse), + } as Response); + }); + + const metadata = await fetchPackageMetadata('test-pkg', '1.0.0'); + expect(metadata.weeklyDownloads).toBe(0); + }); + + it('handles missing version in registry data', async () => { + const registryResponse = { + name: 'test-pkg', + maintainers: [], + time: { created: '2022-01-01T00:00:00.000Z' }, + versions: {}, + }; + + fetchSpy.mockImplementation((url: string | URL | Request) => { + const urlStr = typeof url === 'string' ? url : url.toString(); + if (urlStr.includes('api.npmjs.org/downloads')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ downloads: 100 }), + } as Response); + } + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(registryResponse), + } as Response); + }); + + const metadata = await fetchPackageMetadata('test-pkg', '99.0.0'); + expect(metadata.publishedAt).toBe('2022-01-01T00:00:00.000Z'); + expect(metadata.hasTypes).toBe(false); + }); + }); +});