From 1e34d6d1318852b259cf12d0237195167928bef7 Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Thu, 23 Apr 2026 13:35:27 +0200 Subject: [PATCH] feat: migrate NuGet API from V2 to V3 Registration + Flat Container Replace V2 download URLs and V3 Flat Container version index with: - V3 Registration API (registration5-gz-semver2) for version queries - V3 Flat Container for package downloads (tracked CDN) Key changes: - Add shared/http-client.ts: gzip-aware HTTPS client with User-Agent - Add shared/nuget-registration.ts: Registration API client with pagination - Rewrite nuget-api.ts: semver sorting, unlisted version filtering - Set User-Agent: ALCops-AzureDevOps on all NuGet requests - Update ARCHITECTURE.md and copilot-instructions.md No breaking YAML changes: 'latest' and 'prerelease' inputs unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/ARCHITECTURE.md | 32 +- .github/copilot-instructions.md | 23 +- shared/http-client.ts | 54 +++ shared/nuget-registration.ts | 71 ++++ shared/types.ts | 31 ++ tasks/install-analyzers/src/nuget-api.ts | 178 ++++----- tasks/install-analyzers/src/task-runner.ts | 4 +- tests/install-analyzers/nuget-api.test.ts | 400 +++++++++++--------- tests/install-analyzers/task-runner.test.ts | 4 +- tests/shared/nuget-registration.test.ts | 312 +++++++++++++++ 10 files changed, 835 insertions(+), 274 deletions(-) create mode 100644 shared/http-client.ts create mode 100644 shared/nuget-registration.ts create mode 100644 tests/shared/nuget-registration.test.ts diff --git a/.github/ARCHITECTURE.md b/.github/ARCHITECTURE.md index acf9768..94750b8 100644 --- a/.github/ARCHITECTURE.md +++ b/.github/ARCHITECTURE.md @@ -50,6 +50,10 @@ A single task with 5+ TFM detection modes was confusing. Separate tasks: azure-devops-extension/ ├── shared/ ← Shared TypeScript modules │ ├── types.ts Constants, types, interfaces +│ ├── logger.ts Logger interface + ADO pipeline logger +│ ├── log-inputs.ts Task input logging helper +│ ├── http-client.ts Gzip-aware HTTPS client with User-Agent +│ ├── nuget-registration.ts NuGet V3 Registration API client │ ├── version-threshold.ts .NET runtime version → TFM mapping │ ├── http-range.ts HTTP Range requests + remote ZIP parsing │ ├── zip-local.ts In-memory ZIP extraction from Buffer @@ -138,13 +142,18 @@ Constants and interfaces used across all tasks: | Export | Value | Used By | |--------|-------|---------| | `AL_COMPILER_DLL` | `'Microsoft.Dynamics.Nav.CodeAnalysis.dll'` | compiler-path | -| `NUGET_PACKAGE_NAME` | `'ALCops.Analyzers'` | nuget-api | +| `NUGET_PACKAGE_NAME` | `'ALCops.Analyzers'` | nuget-api, nuget-registration | | `NUGET_FLAT_CONTAINER` | `'https://api.nuget.org/v3-flatcontainer'` | nuget-api, nuget-devtools | +| `NUGET_REGISTRATION_BASE` | `'https://api.nuget.org/v3/registration5-gz-semver2'` | nuget-registration | | `VS_MARKETPLACE_API` | VS Marketplace gallery endpoint | marketplace | | `AL_EXTENSION_ID` | `'ms-dynamics-smb.al'` | marketplace | | `VSIX_DLL_PATH` | `'extension/bin/Analyzers/...'` | vsix-tfm | | `TFM_PREFERENCE` | `['net10.0', ..., 'netstandard2.0']` | nuget-extractor, nuget-devtools | | `TfmDetectionResult` | Interface: `{ tfm, source, details? }` | All detection modules | +| `RegistrationIndex` | Interface: Registration API index response | nuget-registration | +| `RegistrationPage` | Interface: Registration API page | nuget-registration | +| `RegistrationLeaf` | Interface: Registration API leaf (version entry) | nuget-registration | +| `RegistrationVersion` | Interface: `{ version, listed, packageContent }` | nuget-api, nuget-registration | ### binary-tfm.ts @@ -154,6 +163,22 @@ Binary search for TFM and assembly version directly from .NET assembly DLL buffe - `detectAssemblyVersionFromBuffer(buffer)` — Searches for `AssemblyFileVersionAttribute` then extracts the version using blob format validation (`\x01\x00` prolog + length byte + version string) - `toShortTfm(longTfm)` — Converts long-form TFM to short form (e.g., `.NETCoreApp,Version=v8.0` → `net8.0`) +### http-client.ts + +Gzip-aware HTTPS client with `User-Agent` header support. Used by `nuget-registration.ts` for the gzip-compressed Registration API and by `nuget-api.ts` for binary package downloads. + +- `httpsGetBuffer(url, userAgent?)` — Fetches a URL, decompresses gzip if `Content-Encoding: gzip`, follows redirects (up to 5 hops) +- `httpsGetJson(url, userAgent?)` — Fetches and JSON-parses in one step + +### nuget-registration.ts + +NuGet V3 Registration API client. Queries the `registration5-gz-semver2` hive (gzip-compressed, SemVer 2.0.0 inclusive) to get version metadata including listing status and download URLs. + +- `parseRegistrationIndex(index)` — Pure function: extracts `RegistrationVersion[]` from a `RegistrationIndex`. Defaults `listed` to `true` when absent. Skips pages without inlined items. +- `queryNuGetRegistration(packageName, userAgent?, logger?)` — Full pipeline: fetches the registration index, resolves external pages in parallel (`Promise.all`), returns all versions. + +Pagination: NuGet.org inlines page items for packages with < 128 versions. For >= 128 versions, pages are external references (no `items` array). The module detects this and fetches external pages in parallel. + ### version-threshold.ts Pure logic — no I/O, no dependencies beyond `types.ts`. @@ -339,7 +364,7 @@ Dev dependencies: TypeScript, esbuild, vitest, eslint, tfx-cli. | `shared/zip-local.test.ts` | In-memory ZIP extraction | 11 | | `shared/vsix-tfm.test.ts` | VSIX → DLL → binary search → TFM chain | 5 | | `shared/bc-artifact-url.test.ts` | Artifact URL parsing + variant construction | 7 | -| `install-analyzers/nuget-api.test.ts` | NuGet API client | 9 | +| `install-analyzers/nuget-api.test.ts` | NuGet API client | 11 | | `install-analyzers/nuget-extractor.test.ts` | ZIP extraction + TFM compat matching | 17 | | `install-analyzers/compiler-path.test.ts` | Binary TFM detection from real fixture DLLs | 15 | | `install-analyzers/task-runner.test.ts` | Core task orchestration | 4 | @@ -347,4 +372,5 @@ Dev dependencies: TypeScript, esbuild, vitest, eslint, tfx-cli. | `detect-tfm-nuget-devtools/*.test.ts` | NuGet DevTools HTTP Range detection + task-runner | 14 | | `detect-tfm-marketplace/*.test.ts` | Marketplace detection + task-runner | 17 | | `shared/log-inputs.test.ts` | Task input logging | 9 | -| **Total** | | **182** | +| `shared/nuget-registration.test.ts` | NuGet V3 Registration API parsing + HTTP | 13 | +| **Total** | | **199** | diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 7ed8af0..1a23d1b 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -45,6 +45,28 @@ npm run package:dev # Bundle + tfx → dev .vsix in ./out/ - `tests/fixtures/` — Real minimal .NET assemblies for PE parsing tests - `scripts/` — CI/CD scripts (version stamping) +### NuGet API Architecture + +The install-analyzers task interacts with NuGet via two APIs: + +1. **V3 Registration API** (`registration5-gz-semver2` hive) for version queries + - Responses are gzip-compressed (handled by `shared/http-client.ts`) + - Returns version metadata including `listed` status and `packageContent` download URLs + - Pagination: pages with < 128 versions have inlined items; >= 128 versions use external page references fetched in parallel + - Module: `shared/nuget-registration.ts` + +2. **V3 Flat Container** for package downloads + - Direct download from `api.nuget.org` CDN (tracked for NuGet.org download statistics) + - Both package ID and version must be lowercased in URLs + - Module: `tasks/install-analyzers/src/nuget-api.ts` + +Key design decisions: +- `parseRegistrationIndex()` is a pure function (no I/O) for easy testing +- `queryNuGetRegistration()` is a shared module usable by any task needing NuGet version info +- `User-Agent: ALCops-AzureDevOps` is set on all HTTP requests for NuGet.org statistics tracking +- Unlisted versions are filtered out during version resolution +- `resolveVersion()` returns a `ResolvedVersion` with both the version string and the `packageContentUrl` from the Registration API (avoids redundant URL construction) + ### Entry point pattern Every task follows the same pattern: @@ -136,7 +158,6 @@ TypeScript and vitest both use the `@shared/*` alias for imports from `shared/`: - **Output variables need `isOutput: true`**: the 4th argument to `tl.setVariable()` must be `true` for downstream tasks to read the value - **Don't commit `tasks/*/dist/`**: these are gitignored build artifacts - **PE fixtures are real binaries**: `tests/fixtures/` contains .NET assemblies with embedded TFM and version attributes. Don't manually edit them. -- **PE fixtures are real binaries**: `tests/fixtures/` contains .NET assemblies. Don't manually edit them. ## Documentation diff --git a/shared/http-client.ts b/shared/http-client.ts new file mode 100644 index 0000000..2daa4a6 --- /dev/null +++ b/shared/http-client.ts @@ -0,0 +1,54 @@ +import * as https from 'https'; +import * as zlib from 'zlib'; + +/** + * Fetch a URL over HTTPS and return the response body as a Buffer. + * Handles gzip Content-Encoding transparently. + * Follows redirects (up to 5 hops). + */ +export function httpsGetBuffer(url: string, userAgent?: string, redirectCount = 0): Promise { + if (redirectCount > 5) { + return Promise.reject(new Error('Too many redirects')); + } + + return new Promise((resolve, reject) => { + const headers: Record = {}; + if (userAgent) { + headers['User-Agent'] = userAgent; + } + + const req = https.request(url, { method: 'GET', headers }, (res) => { + if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + resolve(httpsGetBuffer(res.headers.location, userAgent, redirectCount + 1)); + return; + } + + if (res.statusCode && (res.statusCode < 200 || res.statusCode >= 300)) { + res.resume(); + reject(new Error(`HTTP ${res.statusCode} for ${url}`)); + return; + } + + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => { + const raw = Buffer.concat(chunks); + const isGzip = res.headers['content-encoding'] === 'gzip'; + resolve(isGzip ? zlib.gunzipSync(raw) : raw); + }); + res.on('error', reject); + }); + + req.on('error', reject); + req.end(); + }); +} + +/** + * Fetch a URL and parse the response as JSON. + * Handles gzip Content-Encoding transparently. + */ +export async function httpsGetJson(url: string, userAgent?: string): Promise { + const buffer = await httpsGetBuffer(url, userAgent); + return JSON.parse(buffer.toString('utf-8')) as T; +} diff --git a/shared/nuget-registration.ts b/shared/nuget-registration.ts new file mode 100644 index 0000000..945d8da --- /dev/null +++ b/shared/nuget-registration.ts @@ -0,0 +1,71 @@ +import { + NUGET_REGISTRATION_BASE, + RegistrationIndex, + RegistrationPage, + RegistrationVersion, +} from './types'; +import { httpsGetJson } from './http-client'; +import { Logger, nullLogger } from './logger'; + +/** + * Parse a RegistrationIndex into a flat array of RegistrationVersion objects. + * Only processes pages that have inlined items; pages without items are skipped. + * Call resolveExternalPages first if the index may contain external page references. + */ +export function parseRegistrationIndex(index: RegistrationIndex): RegistrationVersion[] { + const versions: RegistrationVersion[] = []; + + for (const page of index.items) { + if (!page.items) continue; + for (const leaf of page.items) { + versions.push({ + version: leaf.catalogEntry.version, + listed: leaf.catalogEntry.listed ?? true, + packageContent: leaf.packageContent, + }); + } + } + + return versions; +} + +/** + * Fetch any external pages (those without inlined items) in parallel, + * mutating the index in place to populate their items arrays. + */ +async function resolveExternalPages(index: RegistrationIndex, userAgent?: string): Promise { + const externalPages = index.items.filter((page) => !page.items); + if (externalPages.length === 0) return; + + const fetched = await Promise.all( + externalPages.map((page) => httpsGetJson(page['@id'], userAgent)), + ); + + for (let i = 0; i < externalPages.length; i++) { + externalPages[i].items = fetched[i].items; + } +} + +/** + * Query the NuGet V3 Registration API for all versions of a package. + * Returns the full list of versions with listing status and download URLs. + * Handles pagination (external pages) transparently. + */ +export async function queryNuGetRegistration( + packageName: string, + userAgent?: string, + logger: Logger = nullLogger, +): Promise { + const lowerId = packageName.toLowerCase(); + const url = `${NUGET_REGISTRATION_BASE}/${lowerId}/index.json`; + logger.debug(`NuGet Registration URL: ${url}`); + + const index = await httpsGetJson(url, userAgent); + + await resolveExternalPages(index, userAgent); + + const versions = parseRegistrationIndex(index); + logger.debug(`Found ${versions.length} total versions (${versions.filter((v) => v.listed).length} listed)`); + + return versions; +} diff --git a/shared/types.ts b/shared/types.ts index eb29157..4c3ce89 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -19,6 +19,9 @@ export const NUGET_PACKAGE_NAME = 'ALCops.Analyzers'; /** NuGet v3 flat container base URL */ export const NUGET_FLAT_CONTAINER = 'https://api.nuget.org/v3-flatcontainer'; +/** NuGet v3 Registration API base URL (gzip + SemVer 2.0.0 hive) */ +export const NUGET_REGISTRATION_BASE = 'https://api.nuget.org/v3/registration5-gz-semver2'; + /** VS Marketplace API endpoint */ export const VS_MARKETPLACE_API = 'https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery?api-version=3.0-preview.1'; @@ -35,3 +38,31 @@ export interface TfmDetectionResult { source: string; details?: string; } + +// ── NuGet V3 Registration API types ── + +export interface RegistrationCatalogEntry { + version: string; + listed?: boolean; +} + +export interface RegistrationLeaf { + catalogEntry: RegistrationCatalogEntry; + packageContent: string; +} + +export interface RegistrationPage { + '@id': string; + items?: RegistrationLeaf[]; +} + +export interface RegistrationIndex { + items: RegistrationPage[]; +} + +/** Parsed version info from the Registration API */ +export interface RegistrationVersion { + version: string; + listed: boolean; + packageContent: string; +} diff --git a/tasks/install-analyzers/src/nuget-api.ts b/tasks/install-analyzers/src/nuget-api.ts index 1a96289..cd41c3a 100644 --- a/tasks/install-analyzers/src/nuget-api.ts +++ b/tasks/install-analyzers/src/nuget-api.ts @@ -1,96 +1,82 @@ -import * as https from 'https'; -import * as fs from 'fs'; -import * as path from 'path'; -import { NUGET_PACKAGE_NAME, NUGET_FLAT_CONTAINER } from '../../../shared/types'; -import { Logger, nullLogger } from '../../../shared/logger'; - -const packageId = NUGET_PACKAGE_NAME.toLowerCase(); - -/** - * Resolve the version to download. - * - 'latest': last stable version from NuGet index - * - 'prerelease': last version including pre-release - * - specific version: returned as-is - */ -export async function resolveVersion(requested: string, logger: Logger = nullLogger): Promise { - if (requested !== 'latest' && requested !== 'prerelease') { - logger.info(`Using specified ALCops version: ${requested}`); - return requested; - } - - logger.info(`Resolving ALCops version: '${requested}'`); - const url = `${NUGET_FLAT_CONTAINER}/${packageId}/index.json`; - logger.debug(`NuGet index URL: ${url}`); - const data = await httpsGet(url); - const json = JSON.parse(data.toString('utf-8')) as { versions: string[] }; - - if (!json.versions || json.versions.length === 0) { - throw new Error(`No versions found for ${NUGET_PACKAGE_NAME}`); - } - - if (requested === 'prerelease') { - const resolved = json.versions[json.versions.length - 1]; - logger.info(`Resolved to: ${resolved}`); - return resolved; - } - - // 'latest': find last stable version (no hyphen in version string) - const stable = json.versions.filter((v) => !v.includes('-')); - if (stable.length === 0) { - throw new Error(`No stable versions found for ${NUGET_PACKAGE_NAME}`); - } - const resolved = stable[stable.length - 1]; - logger.info(`Resolved to: ${resolved}`); - return resolved; -} - -/** Build the download URL for a specific version. */ -export function getDownloadUrl(version: string): string { - return `${NUGET_FLAT_CONTAINER}/${packageId}/${version}/${packageId}.${version}.nupkg`; -} - -/** Download the .nupkg to a dest directory, return the file path. */ -export async function downloadPackage(version: string, destDir: string, logger: Logger = nullLogger): Promise { - const url = getDownloadUrl(version); - logger.info('Downloading ALCops package from NuGet...'); - logger.debug(`Download URL: ${url}`); - if (!fs.existsSync(destDir)) { - fs.mkdirSync(destDir, { recursive: true }); - } - const destPath = path.join(destDir, 'package.nupkg'); - const data = await httpsGet(url); - fs.writeFileSync(destPath, data); - logger.debug(`Package saved to: ${destPath} (${data.length} bytes)`); - return destPath; -} - -// ── Internal helper ── - -function httpsGet(url: string, redirectCount = 0): Promise { - if (redirectCount > 5) { - return Promise.reject(new Error('Too many redirects')); - } - - return new Promise((resolve, reject) => { - const req = https.request(url, { method: 'GET' }, (res) => { - if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { - resolve(httpsGet(res.headers.location, redirectCount + 1)); - return; - } - - if (res.statusCode && (res.statusCode < 200 || res.statusCode >= 300)) { - res.resume(); - reject(new Error(`HTTP ${res.statusCode} for ${url}`)); - return; - } - - const chunks: Buffer[] = []; - res.on('data', (chunk: Buffer) => chunks.push(chunk)); - res.on('end', () => resolve(Buffer.concat(chunks))); - res.on('error', reject); - }); - - req.on('error', reject); - req.end(); - }); -} +import * as fs from 'fs'; +import * as path from 'path'; +import { compare, prerelease, valid } from 'semver'; +import { NUGET_PACKAGE_NAME, NUGET_FLAT_CONTAINER, RegistrationVersion } from '../../../shared/types'; +import { Logger, nullLogger } from '../../../shared/logger'; +import { queryNuGetRegistration } from '../../../shared/nuget-registration'; +import { httpsGetBuffer } from '../../../shared/http-client'; + +const packageId = NUGET_PACKAGE_NAME.toLowerCase(); +const USER_AGENT = 'ALCops-AzureDevOps'; + +export interface ResolvedVersion { + version: string; + packageContentUrl?: string; +} + +/** + * Resolve the version to download. + * - 'latest': last listed stable version from NuGet Registration API + * - 'prerelease': last listed version including pre-release + * - specific version: returned as-is (no packageContentUrl) + */ +export async function resolveVersion(requested: string, logger: Logger = nullLogger): Promise { + if (requested !== 'latest' && requested !== 'prerelease') { + logger.info(`Using specified ALCops version: ${requested}`); + return { version: requested }; + } + + logger.info(`Resolving ALCops version: '${requested}'`); + const allVersions = await queryNuGetRegistration(NUGET_PACKAGE_NAME, USER_AGENT, logger); + + const listed = allVersions + .filter((v) => v.listed) + .filter((v) => valid(v.version) !== null); + + if (listed.length === 0) { + throw new Error(`No listed versions found for ${NUGET_PACKAGE_NAME}`); + } + + let candidates: RegistrationVersion[]; + if (requested === 'latest') { + candidates = listed.filter((v) => prerelease(v.version) === null); + if (candidates.length === 0) { + throw new Error(`No stable versions found for ${NUGET_PACKAGE_NAME}`); + } + } else { + // 'prerelease': all listed versions + candidates = listed; + } + + candidates.sort((a, b) => compare(a.version, b.version)); + const best = candidates[candidates.length - 1]; + + logger.info(`Resolved to: ${best.version}`); + return { version: best.version, packageContentUrl: best.packageContent }; +} + +/** Build the V3 Flat Container download URL for a specific version. */ +export function getDownloadUrl(version: string): string { + const lowerVersion = version.toLowerCase(); + return `${NUGET_FLAT_CONTAINER}/${packageId}/${lowerVersion}/${packageId}.${lowerVersion}.nupkg`; +} + +/** Download the .nupkg to a dest directory, return the file path. */ +export async function downloadPackage( + version: string, + destDir: string, + logger: Logger = nullLogger, + packageContentUrl?: string, +): Promise { + const url = packageContentUrl ?? getDownloadUrl(version); + logger.info('Downloading ALCops package from NuGet...'); + logger.debug(`Download URL: ${url}`); + if (!fs.existsSync(destDir)) { + fs.mkdirSync(destDir, { recursive: true }); + } + const destPath = path.join(destDir, 'package.nupkg'); + const data = await httpsGetBuffer(url, USER_AGENT); + fs.writeFileSync(destPath, data); + logger.debug(`Package saved to: ${destPath} (${data.length} bytes)`); + return destPath; +} \ No newline at end of file diff --git a/tasks/install-analyzers/src/task-runner.ts b/tasks/install-analyzers/src/task-runner.ts index 3728289..df803b1 100644 --- a/tasks/install-analyzers/src/task-runner.ts +++ b/tasks/install-analyzers/src/task-runner.ts @@ -45,8 +45,8 @@ export async function run(): Promise { } else { const resolved = await resolveVersion(version, logger); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'alcops-')); - nupkgPath = await downloadPackage(resolved, tmpDir, logger); - tl.setVariable('alcopsVersion', resolved); + nupkgPath = await downloadPackage(resolved.version, tmpDir, logger, resolved.packageContentUrl); + tl.setVariable('alcopsVersion', resolved.version); } // 4. Extract diff --git a/tests/install-analyzers/nuget-api.test.ts b/tests/install-analyzers/nuget-api.test.ts index e735460..91e70bc 100644 --- a/tests/install-analyzers/nuget-api.test.ts +++ b/tests/install-analyzers/nuget-api.test.ts @@ -1,170 +1,230 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { EventEmitter } from 'events'; -import * as https from 'https'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import { NUGET_FLAT_CONTAINER } from '@shared/types'; - -// ── Mock https module ── -vi.mock('https', () => ({ - request: vi.fn(), -})); - -const mockRequest = https.request as unknown as ReturnType; - -import { - resolveVersion, - getDownloadUrl, - downloadPackage, -} from '../../tasks/install-analyzers/src/nuget-api'; - -// ── Helpers ── - -interface MockResponseOptions { - statusCode?: number; - headers?: Record; - body?: Buffer; -} - -function enqueueResponse(opts: MockResponseOptions) { - mockRequest.mockImplementationOnce( - (_url: string, _reqOpts: unknown, cb: (res: EventEmitter & { statusCode?: number; headers: Record }) => void) => { - const res = new EventEmitter() as EventEmitter & { - statusCode?: number; - headers: Record; - resume: () => void; - }; - res.statusCode = opts.statusCode ?? 200; - res.headers = opts.headers ?? {}; - res.resume = () => {}; - - process.nextTick(() => { - cb(res); - if (opts.body) { - res.emit('data', opts.body); - } - res.emit('end'); - }); - - const req = new EventEmitter(); - (req as EventEmitter & { end: () => void }).end = () => {}; - return req; - }, - ); -} - -beforeEach(() => { - vi.clearAllMocks(); -}); - -// ──────────────────────────────────────────────────────────────── -// resolveVersion -// ──────────────────────────────────────────────────────────────── -describe('resolveVersion', () => { - const indexJson = { - versions: [ - '0.1.0-beta.1', - '0.1.0', - '0.2.0', - '0.3.0-rc.1', - ], - }; - - it('returns the last stable version for "latest"', async () => { - enqueueResponse({ - statusCode: 200, - body: Buffer.from(JSON.stringify(indexJson)), - }); - - const version = await resolveVersion('latest'); - expect(version).toBe('0.2.0'); - }); - - it('returns the last version (including pre-release) for "prerelease"', async () => { - enqueueResponse({ - statusCode: 200, - body: Buffer.from(JSON.stringify(indexJson)), - }); - - const version = await resolveVersion('prerelease'); - expect(version).toBe('0.3.0-rc.1'); - }); - - it('returns specific version as-is', async () => { - const version = await resolveVersion('1.2.3'); - expect(version).toBe('1.2.3'); - expect(mockRequest).not.toHaveBeenCalled(); - }); - - it('throws when no versions are found', async () => { - enqueueResponse({ - statusCode: 200, - body: Buffer.from(JSON.stringify({ versions: [] })), - }); - - await expect(resolveVersion('latest')).rejects.toThrow('No versions found'); - }); - - it('throws when no stable versions exist for "latest"', async () => { - enqueueResponse({ - statusCode: 200, - body: Buffer.from(JSON.stringify({ versions: ['1.0.0-beta.1'] })), - }); - - await expect(resolveVersion('latest')).rejects.toThrow('No stable versions'); - }); -}); - -// ──────────────────────────────────────────────────────────────── -// getDownloadUrl -// ──────────────────────────────────────────────────────────────── -describe('getDownloadUrl', () => { - it('formats the URL correctly', () => { - const url = getDownloadUrl('1.2.3'); - expect(url).toBe( - `${NUGET_FLAT_CONTAINER}/alcops.analyzers/1.2.3/alcops.analyzers.1.2.3.nupkg`, - ); - }); - - it('handles pre-release versions', () => { - const url = getDownloadUrl('1.0.0-beta.1'); - expect(url).toBe( - `${NUGET_FLAT_CONTAINER}/alcops.analyzers/1.0.0-beta.1/alcops.analyzers.1.0.0-beta.1.nupkg`, - ); - }); -}); - -// ──────────────────────────────────────────────────────────────── -// downloadPackage -// ──────────────────────────────────────────────────────────────── -describe('downloadPackage', () => { - it('downloads and writes the .nupkg to disk', async () => { - const fakeContent = Buffer.from('PK-fake-nupkg-content'); - enqueueResponse({ statusCode: 200, body: fakeContent }); - - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nuget-api-test-')); - try { - const result = await downloadPackage('1.0.0', tmpDir); - expect(result).toBe(path.join(tmpDir, 'package.nupkg')); - expect(fs.existsSync(result)).toBe(true); - expect(fs.readFileSync(result)).toEqual(fakeContent); - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); - - it('creates the destination directory if it does not exist', async () => { - const fakeContent = Buffer.from('PK-data'); - enqueueResponse({ statusCode: 200, body: fakeContent }); - - const tmpDir = path.join(os.tmpdir(), `nuget-api-test-nested-${Date.now()}`); - const nestedDir = path.join(tmpDir, 'sub', 'dir'); - try { - const result = await downloadPackage('2.0.0', nestedDir); - expect(fs.existsSync(result)).toBe(true); - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); -}); +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { EventEmitter } from 'events'; +import * as https from 'https'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { NUGET_FLAT_CONTAINER, RegistrationVersion } from '@shared/types'; + +// ── Mock https module (for downloadPackage binary fetches) ── +vi.mock('https', () => ({ + request: vi.fn(), +})); + +// ── Mock nuget-registration module ── +vi.mock('../../shared/nuget-registration', () => ({ + queryNuGetRegistration: vi.fn(), +})); + +const mockRequest = https.request as unknown as ReturnType; + +import { queryNuGetRegistration } from '@shared/nuget-registration'; +import { + resolveVersion, + getDownloadUrl, + downloadPackage, +} from '../../tasks/install-analyzers/src/nuget-api'; + +const mockQueryRegistration = queryNuGetRegistration as ReturnType; + +// ── Helpers ── + +interface MockResponseOptions { + statusCode?: number; + headers?: Record; + body?: Buffer; +} + +function enqueueResponse(opts: MockResponseOptions) { + mockRequest.mockImplementationOnce( + (_url: string, _reqOpts: unknown, cb: (res: EventEmitter & { statusCode?: number; headers: Record }) => void) => { + const res = new EventEmitter() as EventEmitter & { + statusCode?: number; + headers: Record; + resume: () => void; + }; + res.statusCode = opts.statusCode ?? 200; + res.headers = opts.headers ?? {}; + res.resume = () => {}; + + process.nextTick(() => { + cb(res); + if (opts.body) { + res.emit('data', opts.body); + } + res.emit('end'); + }); + + const req = new EventEmitter(); + (req as EventEmitter & { end: () => void }).end = () => {}; + return req; + }, + ); +} + +function makeVersion(version: string, listed = true): RegistrationVersion { + return { + version, + listed, + packageContent: `https://api.nuget.org/v3-flatcontainer/alcops.analyzers/${version.toLowerCase()}/alcops.analyzers.${version.toLowerCase()}.nupkg`, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// ──────────────────────────────────────────────────────────────── +// resolveVersion +// ──────────────────────────────────────────────────────────────── +describe('resolveVersion', () => { + it('returns the last listed stable version for "latest"', async () => { + mockQueryRegistration.mockResolvedValue([ + makeVersion('0.1.0-beta.1'), + makeVersion('0.1.0'), + makeVersion('0.2.0'), + makeVersion('0.3.0-rc.1'), + ]); + + const result = await resolveVersion('latest'); + expect(result.version).toBe('0.2.0'); + expect(result.packageContentUrl).toBeDefined(); + }); + + it('returns the last listed version (including pre-release) for "prerelease"', async () => { + mockQueryRegistration.mockResolvedValue([ + makeVersion('0.1.0'), + makeVersion('0.2.0'), + makeVersion('0.3.0-rc.1'), + ]); + + const result = await resolveVersion('prerelease'); + expect(result.version).toBe('0.3.0-rc.1'); + }); + + it('returns specific version as-is without querying NuGet', async () => { + const result = await resolveVersion('1.2.3'); + expect(result.version).toBe('1.2.3'); + expect(result.packageContentUrl).toBeUndefined(); + expect(mockQueryRegistration).not.toHaveBeenCalled(); + }); + + it('filters out unlisted versions', async () => { + mockQueryRegistration.mockResolvedValue([ + makeVersion('0.1.0', true), + makeVersion('0.2.0', false), // unlisted + makeVersion('0.3.0', true), + ]); + + const result = await resolveVersion('latest'); + expect(result.version).toBe('0.3.0'); + }); + + it('throws when no listed versions exist', async () => { + mockQueryRegistration.mockResolvedValue([ + makeVersion('1.0.0', false), + ]); + + await expect(resolveVersion('latest')).rejects.toThrow('No listed versions'); + }); + + it('throws when no stable versions exist for "latest"', async () => { + mockQueryRegistration.mockResolvedValue([ + makeVersion('1.0.0-beta.1', true), + ]); + + await expect(resolveVersion('latest')).rejects.toThrow('No stable versions'); + }); + + it('sorts by semver, not lexicographically', async () => { + mockQueryRegistration.mockResolvedValue([ + makeVersion('0.10.0'), + makeVersion('0.2.0'), + makeVersion('0.9.0'), + ]); + + const result = await resolveVersion('latest'); + expect(result.version).toBe('0.10.0'); + }); +}); + +// ──────────────────────────────────────────────────────────────── +// getDownloadUrl +// ──────────────────────────────────────────────────────────────── +describe('getDownloadUrl', () => { + it('formats the V3 Flat Container URL correctly', () => { + const url = getDownloadUrl('1.2.3'); + expect(url).toBe( + `${NUGET_FLAT_CONTAINER}/alcops.analyzers/1.2.3/alcops.analyzers.1.2.3.nupkg`, + ); + }); + + it('lowercases the version in the URL', () => { + const url = getDownloadUrl('1.0.0-Beta.1'); + expect(url).toContain('/1.0.0-beta.1/'); + expect(url).toContain('alcops.analyzers.1.0.0-beta.1.nupkg'); + }); +}); + +// ──────────────────────────────────────────────────────────────── +// downloadPackage +// ──────────────────────────────────────────────────────────────── +describe('downloadPackage', () => { + it('downloads and writes the .nupkg to disk', async () => { + const fakeContent = Buffer.from('PK-fake-nupkg-content'); + enqueueResponse({ statusCode: 200, body: fakeContent }); + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nuget-api-test-')); + try { + const result = await downloadPackage('1.0.0', tmpDir); + expect(result).toBe(path.join(tmpDir, 'package.nupkg')); + expect(fs.existsSync(result)).toBe(true); + expect(fs.readFileSync(result)).toEqual(fakeContent); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('creates the destination directory if it does not exist', async () => { + const fakeContent = Buffer.from('PK-data'); + enqueueResponse({ statusCode: 200, body: fakeContent }); + + const tmpDir = path.join(os.tmpdir(), `nuget-api-test-nested-${Date.now()}`); + const nestedDir = path.join(tmpDir, 'sub', 'dir'); + try { + const result = await downloadPackage('2.0.0', nestedDir); + expect(fs.existsSync(result)).toBe(true); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('uses packageContentUrl when provided', async () => { + const fakeContent = Buffer.from('PK-content'); + enqueueResponse({ statusCode: 200, body: fakeContent }); + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nuget-api-test-')); + const customUrl = 'https://api.nuget.org/v3-flatcontainer/alcops.analyzers/1.0.0/alcops.analyzers.1.0.0.nupkg'; + try { + await downloadPackage('1.0.0', tmpDir, undefined, customUrl); + const calledUrl = mockRequest.mock.calls[0][0]; + expect(calledUrl).toBe(customUrl); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('sets User-Agent header', async () => { + const fakeContent = Buffer.from('PK-content'); + enqueueResponse({ statusCode: 200, body: fakeContent }); + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nuget-api-test-')); + try { + await downloadPackage('1.0.0', tmpDir); + const calledOpts = mockRequest.mock.calls[0][1] as { headers?: Record }; + expect(calledOpts.headers?.['User-Agent']).toBe('ALCops-AzureDevOps'); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); \ No newline at end of file diff --git a/tests/install-analyzers/task-runner.test.ts b/tests/install-analyzers/task-runner.test.ts index 1475439..055f8c1 100644 --- a/tests/install-analyzers/task-runner.test.ts +++ b/tests/install-analyzers/task-runner.test.ts @@ -73,7 +73,7 @@ describe('task-runner', () => { return undefined; }); - mockResolveVersion.mockResolvedValue('1.0.0'); + mockResolveVersion.mockResolvedValue({ version: '1.0.0' }); mockDownloadPackage.mockResolvedValue('/tmp/package.nupkg'); mockExtractAnalyzers.mockResolvedValue({ extractedPath: '/build/src/.alcops', @@ -109,7 +109,7 @@ describe('task-runner', () => { source: 'compiler-path', details: 'Microsoft.Dynamics.Nav.CodeAnalysis.dll v15.0.0.0', }); - mockResolveVersion.mockResolvedValue('2.0.0'); + mockResolveVersion.mockResolvedValue({ version: '2.0.0' }); mockDownloadPackage.mockResolvedValue('/tmp/package.nupkg'); mockExtractAnalyzers.mockResolvedValue({ extractedPath: '/build/src/.alcops', diff --git a/tests/shared/nuget-registration.test.ts b/tests/shared/nuget-registration.test.ts new file mode 100644 index 0000000..10f2b1f --- /dev/null +++ b/tests/shared/nuget-registration.test.ts @@ -0,0 +1,312 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { EventEmitter } from 'events'; +import * as https from 'https'; +import * as zlib from 'zlib'; + +// ── Mock https module ── +vi.mock('https', () => ({ + request: vi.fn(), +})); + +const mockRequest = https.request as unknown as ReturnType; + +import { parseRegistrationIndex, queryNuGetRegistration } from '@shared/nuget-registration'; +import { RegistrationIndex, NUGET_REGISTRATION_BASE } from '@shared/types'; + +// ── Helpers ── + +interface MockResponseOptions { + statusCode?: number; + headers?: Record; + body?: Buffer; +} + +function enqueueResponse(opts: MockResponseOptions) { + mockRequest.mockImplementationOnce( + (_url: string, _reqOpts: unknown, cb: (res: EventEmitter & { statusCode?: number; headers: Record }) => void) => { + const res = new EventEmitter() as EventEmitter & { + statusCode?: number; + headers: Record; + resume: () => void; + }; + res.statusCode = opts.statusCode ?? 200; + res.headers = opts.headers ?? {}; + res.resume = () => {}; + + process.nextTick(() => { + cb(res); + if (opts.body) { + res.emit('data', opts.body); + } + res.emit('end'); + }); + + const req = new EventEmitter(); + (req as EventEmitter & { end: () => void }).end = () => {}; + return req; + }, + ); +} + +function gzipBuffer(data: object): Buffer { + return zlib.gzipSync(Buffer.from(JSON.stringify(data), 'utf-8')); +} + +function enqueueGzipResponse(data: object) { + enqueueResponse({ + statusCode: 200, + headers: { 'content-encoding': 'gzip' }, + body: gzipBuffer(data), + }); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// ──────────────────────────────────────────────────────────────── +// parseRegistrationIndex (pure function, no HTTP) +// ──────────────────────────────────────────────────────────────── +describe('parseRegistrationIndex', () => { + it('extracts versions from inlined page items', () => { + const index: RegistrationIndex = { + items: [{ + '@id': 'https://example.com/page', + items: [ + { + catalogEntry: { version: '1.0.0', listed: true }, + packageContent: 'https://cdn.example.com/pkg/1.0.0.nupkg', + }, + { + catalogEntry: { version: '2.0.0', listed: false }, + packageContent: 'https://cdn.example.com/pkg/2.0.0.nupkg', + }, + ], + }], + }; + + const result = parseRegistrationIndex(index); + expect(result).toEqual([ + { version: '1.0.0', listed: true, packageContent: 'https://cdn.example.com/pkg/1.0.0.nupkg' }, + { version: '2.0.0', listed: false, packageContent: 'https://cdn.example.com/pkg/2.0.0.nupkg' }, + ]); + }); + + it('defaults listed to true when field is absent', () => { + const index: RegistrationIndex = { + items: [{ + '@id': 'https://example.com/page', + items: [{ + catalogEntry: { version: '1.0.0' }, + packageContent: 'https://cdn.example.com/pkg/1.0.0.nupkg', + }], + }], + }; + + const result = parseRegistrationIndex(index); + expect(result[0].listed).toBe(true); + }); + + it('handles multiple pages', () => { + const index: RegistrationIndex = { + items: [ + { + '@id': 'https://example.com/page/0', + items: [{ + catalogEntry: { version: '1.0.0', listed: true }, + packageContent: 'https://cdn.example.com/pkg/1.0.0.nupkg', + }], + }, + { + '@id': 'https://example.com/page/1', + items: [{ + catalogEntry: { version: '2.0.0', listed: true }, + packageContent: 'https://cdn.example.com/pkg/2.0.0.nupkg', + }], + }, + ], + }; + + const result = parseRegistrationIndex(index); + expect(result).toHaveLength(2); + expect(result[0].version).toBe('1.0.0'); + expect(result[1].version).toBe('2.0.0'); + }); + + it('skips pages without inlined items', () => { + const index: RegistrationIndex = { + items: [ + { + '@id': 'https://example.com/page/0', + items: [{ + catalogEntry: { version: '1.0.0', listed: true }, + packageContent: 'https://cdn.example.com/pkg/1.0.0.nupkg', + }], + }, + { + '@id': 'https://example.com/page/1', + // no items (external page, not yet resolved) + }, + ], + }; + + const result = parseRegistrationIndex(index); + expect(result).toHaveLength(1); + expect(result[0].version).toBe('1.0.0'); + }); + + it('returns empty array for empty index', () => { + const index: RegistrationIndex = { items: [] }; + expect(parseRegistrationIndex(index)).toEqual([]); + }); + + it('returns empty array when all pages lack items', () => { + const index: RegistrationIndex = { + items: [{ '@id': 'https://example.com/page/0' }], + }; + expect(parseRegistrationIndex(index)).toEqual([]); + }); +}); + +// ──────────────────────────────────────────────────────────────── +// queryNuGetRegistration (HTTP integration with mocked https) +// ──────────────────────────────────────────────────────────────── +describe('queryNuGetRegistration', () => { + it('returns versions from a fully-inlined index', async () => { + const indexData: RegistrationIndex = { + items: [{ + '@id': 'https://example.com/page', + items: [ + { + catalogEntry: { version: '1.0.0', listed: true }, + packageContent: 'https://cdn.example.com/pkg/1.0.0.nupkg', + }, + ], + }], + }; + + enqueueGzipResponse(indexData); + + const result = await queryNuGetRegistration('TestPackage', 'TestAgent/1.0'); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + version: '1.0.0', + listed: true, + packageContent: 'https://cdn.example.com/pkg/1.0.0.nupkg', + }); + }); + + it('lowercases the package ID in the registration URL', async () => { + const indexData: RegistrationIndex = { items: [{ '@id': 'https://example.com/page', items: [] }] }; + enqueueGzipResponse(indexData); + + await queryNuGetRegistration('MyPackage.Name', 'TestAgent/1.0'); + + const calledUrl = mockRequest.mock.calls[0][0]; + expect(calledUrl).toBe(`${NUGET_REGISTRATION_BASE}/mypackage.name/index.json`); + }); + + it('passes User-Agent header', async () => { + const indexData: RegistrationIndex = { items: [{ '@id': 'https://example.com/page', items: [] }] }; + enqueueGzipResponse(indexData); + + await queryNuGetRegistration('Pkg', 'MyClient/2.0'); + + const calledOpts = mockRequest.mock.calls[0][1] as { headers?: Record }; + expect(calledOpts.headers?.['User-Agent']).toBe('MyClient/2.0'); + }); + + it('fetches external pages and merges results', async () => { + const indexData = { + items: [{ + '@id': 'https://api.nuget.org/v3/registration5-gz-semver2/pkg/page/1.0.0/2.0.0.json', + // no items = external page + }], + }; + + const pageData = { + '@id': 'https://api.nuget.org/v3/registration5-gz-semver2/pkg/page/1.0.0/2.0.0.json', + items: [ + { + catalogEntry: { version: '1.0.0', listed: true }, + packageContent: 'https://cdn.example.com/pkg/1.0.0.nupkg', + }, + { + catalogEntry: { version: '2.0.0', listed: false }, + packageContent: 'https://cdn.example.com/pkg/2.0.0.nupkg', + }, + ], + }; + + // First call: index + enqueueGzipResponse(indexData); + // Second call: external page + enqueueGzipResponse(pageData); + + const result = await queryNuGetRegistration('Pkg'); + expect(result).toHaveLength(2); + expect(result[0].version).toBe('1.0.0'); + expect(result[1].version).toBe('2.0.0'); + expect(result[1].listed).toBe(false); + }); + + it('handles mix of inlined and external pages', async () => { + const indexData = { + items: [ + { + '@id': 'https://example.com/page/0', + items: [{ + catalogEntry: { version: '1.0.0', listed: true }, + packageContent: 'https://cdn.example.com/1.0.0.nupkg', + }], + }, + { + '@id': 'https://example.com/page/1', + // external + }, + ], + }; + + const externalPage = { + '@id': 'https://example.com/page/1', + items: [{ + catalogEntry: { version: '2.0.0', listed: true }, + packageContent: 'https://cdn.example.com/2.0.0.nupkg', + }], + }; + + enqueueGzipResponse(indexData); + enqueueGzipResponse(externalPage); + + const result = await queryNuGetRegistration('Pkg'); + expect(result).toHaveLength(2); + expect(result.map((v) => v.version)).toEqual(['1.0.0', '2.0.0']); + }); + + it('handles non-gzip responses', async () => { + const indexData: RegistrationIndex = { + items: [{ + '@id': 'https://example.com/page', + items: [{ + catalogEntry: { version: '1.0.0', listed: true }, + packageContent: 'https://cdn.example.com/1.0.0.nupkg', + }], + }], + }; + + // No gzip + enqueueResponse({ + statusCode: 200, + body: Buffer.from(JSON.stringify(indexData)), + }); + + const result = await queryNuGetRegistration('Pkg'); + expect(result).toHaveLength(1); + }); + + it('rejects on non-200 status code', async () => { + enqueueResponse({ statusCode: 404 }); + + await expect(queryNuGetRegistration('NonExistent')).rejects.toThrow('HTTP 404'); + }); +});