From 95be8cdbbc41759bd1edd2f70848250348d6f819 Mon Sep 17 00:00:00 2001 From: Chris Dayne <1642847+cdayne@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:54:39 +1100 Subject: [PATCH 01/13] fix: send ARM requests through HTTPS_PROXY ARM requests use Node's global fetch, which ignores HTTPS_PROXY and HTTP_PROXY on its own. The troubleshooting docs told users behind a proxy to set HTTPS_PROXY, but requests still went direct. Token requests from @azure/identity go through the Azure SDK's proxyPolicy, which reads the variables itself. At start-up the CLI now applies that policy's rules to fetch through an undici global dispatcher, on every supported Node.js version: - one proxy for every request: HTTPS_PROXY, then ALL_PROXY, then HTTP_PROXY, each in upper case before lower case; - NO_PROXY (then no_proxy) entries match an exact host, or a leading-dot domain and its subdomains; other forms (*, ports, a bare domain for its subdomains) are not supported, as in the SDK; - a user name or password in the proxy URL is sent as Basic Proxy-Authorization; - HTTPS requests are tunnelled (CONNECT) and plain-HTTP requests are forwarded to the proxy. The SDK has no public API for these rules (core-rest-pipeline's proxyPolicy is not meant for direct use), so they are reproduced here. Run side by side, the SDK pipeline and the CLI picked the same proxy, bypass and credentials for every environment tried. The proxy URL is never logged. Adds undici ^7.30.1, which supports Node.js 20.18.1 and later and sets the global dispatcher that Node's built-in fetch uses. --- docs/troubleshooting/common-errors.md | 4 +- package-lock.json | 12 +- package.json | 3 +- src/cli/index.ts | 4 + src/lib/proxy.ts | 131 ++++++++++++++++++ tests/unit/cli/index.test.ts | 29 +++- tests/unit/lib/proxy.test.ts | 188 ++++++++++++++++++++++++++ 7 files changed, 366 insertions(+), 5 deletions(-) create mode 100644 src/lib/proxy.ts create mode 100644 tests/unit/lib/proxy.test.ts diff --git a/docs/troubleshooting/common-errors.md b/docs/troubleshooting/common-errors.md index 91f2724e..b4712f0d 100644 --- a/docs/troubleshooting/common-errors.md +++ b/docs/troubleshooting/common-errors.md @@ -208,7 +208,9 @@ See [Pipeline Recovery](pipeline-recovery.md) for CI/CD-specific recovery steps. **Solution:** 1. Test connectivity: `curl -I https://management.azure.com` -2. If behind a proxy, set `HTTPS_PROXY` environment variable +2. If behind a proxy, set the `HTTPS_PROXY` environment variable. The CLI reads the proxy settings the way `@azure/identity` reads them for its sign-in requests: every request uses the first of `HTTPS_PROXY`, `ALL_PROXY` and `HTTP_PROXY` that is set (each name in upper case before lower case), and a user name and password in the proxy URL are sent to the proxy + - For hosts that must bypass the proxy, set `NO_PROXY` to a comma-separated list of exact host names (`management.azure.com`) or domains with a leading dot (`.azure.com`, which also matches `azure.com`). Other forms, such as `*` or `azure.com` for its subdomains, are not supported + - When you sign in with the Azure CLI, Azure Developer CLI or Azure PowerShell, that tool requests the token itself, using its own proxy settings 3. If using private endpoints, ensure you're on the correct network --- diff --git a/package-lock.json b/package-lock.json index 07539648..b3d2b98a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,8 @@ "commander": "^14.0.3", "he": "^1.2.0", "js-yaml": "^4.1.1", - "simple-git": "^3.35.2" + "simple-git": "^3.35.2", + "undici": "^7.30.1" }, "bin": { "apiops": "dist/cli/index.js" @@ -3264,6 +3265,15 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/undici": { + "version": "7.30.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz", + "integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.19.2", "dev": true, diff --git a/package.json b/package.json index 71f69d91..240595a8 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,8 @@ "commander": "^14.0.3", "he": "^1.2.0", "js-yaml": "^4.1.1", - "simple-git": "^3.35.2" + "simple-git": "^3.35.2", + "undici": "^7.30.1" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/src/cli/index.ts b/src/cli/index.ts index e0682b40..596b788d 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -9,6 +9,7 @@ import { Command, Option } from 'commander'; import { logger, parseLogLevel } from '../lib/logger.js'; +import { applyProxyFromEnv } from '../lib/proxy.js'; import { createExtractCommand } from './extract-command.js'; import { createPublishCommand } from './publish-command.js'; import { createInitCommand } from './init-command.js'; @@ -52,6 +53,9 @@ program.hook('preAction', (thisCommand) => { logger.configure({ level: parseLogLevel(opts.logLevel ?? 'info') }); + // Send ARM requests through HTTPS_PROXY / HTTP_PROXY when set. + applyProxyFromEnv(); + // Set DefaultAzureCredential env vars from explicit auth flags. // This ensures the ServicePrincipal credential is tried first, // avoiding interactive browser prompts in CI/CD pipelines. diff --git a/src/lib/proxy.ts b/src/lib/proxy.ts new file mode 100644 index 00000000..ce2a8df6 --- /dev/null +++ b/src/lib/proxy.ts @@ -0,0 +1,131 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +/** + * Proxy support for the CLI's ARM requests. + * + * ARM requests use Node's global `fetch`, which ignores `HTTPS_PROXY` / + * `HTTP_PROXY` on its own. Token requests made by `@azure/identity` go + * through the Azure SDK's proxy policy (`proxyPolicy` in + * `@typespec/ts-http-runtime`), which reads the variables itself. So that + * both are routed alike on every supported Node.js version, the CLI applies + * that policy's rules to `fetch` through an `undici` global dispatcher: + * + * - one proxy for every request, from `HTTPS_PROXY`, then `ALL_PROXY`, then + * `HTTP_PROXY`, each name read in upper case before lower case; + * - `NO_PROXY` (then `no_proxy`) is a comma-separated list of host names; a + * request bypasses the proxy when its host equals an entry, or when an + * entry starts with `.` and the host is that domain or ends with it; + * - a user name or password in the proxy URL is sent as Basic + * `Proxy-Authorization`; + * - HTTPS requests are tunnelled through the proxy (CONNECT) and plain-HTTP + * requests are forwarded to it. + */ + +import { Agent, getGlobalDispatcher, ProxyAgent, setGlobalDispatcher, type Dispatcher } from 'undici'; +import { logger } from './logger.js'; + +/** Proxy variables in the order the Azure SDK's proxy policy reads them. */ +const PROXY_VARIABLES = ['HTTPS_PROXY', 'ALL_PROXY', 'HTTP_PROXY'] as const; + +/** + * The first set variable among `names`, each read in upper case before lower + * case (as the Azure SDK reads them): its actual name and value. + */ +function readEnvironment( + env: NodeJS.ProcessEnv, + names: readonly string[], +): { name: string; value: string } | undefined { + for (const upper of names) { + for (const name of [upper, upper.toLowerCase()]) { + const value = env[name]; + if (value) { + return { name, value }; + } + } + } + return undefined; +} + +/** `NO_PROXY` entries, split and trimmed as the Azure SDK splits them. */ +export function parseNoProxy(value: string | undefined): string[] { + return (value ?? '') + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +} + +/** + * True when a request to `host` bypasses the proxy, by the Azure SDK's rule: + * the host equals an entry, or an entry starts with `.` and the host ends + * with it or equals it without the dot. + */ +export function isProxyBypassed(host: string, noProxy: readonly string[]): boolean { + return noProxy.some((entry) => + entry.startsWith('.') ? host.endsWith(entry) || host === entry.slice(1) : host === entry, + ); +} + +/** + * A dispatcher that sends requests through `proxyUrl`, except to hosts that + * `noProxy` bypasses, which go direct. + */ +function createProxyDispatcher(proxyUrl: string, noProxy: readonly string[]): { + dispatcher: Dispatcher; + close: () => Promise; +} { + const url = new URL(proxyUrl); + // Send the credentials as the Azure SDK's proxy agents do: Basic auth when + // either a user name or a password is present. + const token = + url.username || url.password + ? `Basic ${Buffer.from(`${decodeURIComponent(url.username)}:${decodeURIComponent(url.password)}`).toString('base64')}` + : undefined; + url.username = ''; + url.password = ''; + // Plain-HTTP requests are forwarded to the proxy rather than tunnelled, as + // the SDK's http-proxy-agent does; HTTPS requests are tunnelled (CONNECT). + const proxied = new ProxyAgent({ uri: url.href, proxyTunnel: false, ...(token ? { token } : {}) }); + const direct = new Agent(); + const dispatcher = proxied.compose((dispatch) => (options, handler) => { + const host = new URL(String(options.origin)).hostname; + return isProxyBypassed(host, noProxy) ? direct.dispatch(options, handler) : dispatch(options, handler); + }); + return { + dispatcher, + close: async () => { + await Promise.all([proxied.close(), direct.close()]); + }, + }; +} + +export type ProxySetupResult = + /** No proxy variable is set. */ + | { status: 'none' } + /** The proxy was applied; `restore` puts the previous dispatcher back. */ + | { status: 'applied'; restore: () => Promise }; + +/** + * Route the CLI's requests through the proxy the Azure SDK would use (from + * `HTTPS_PROXY`, `ALL_PROXY` or `HTTP_PROXY`), honouring `NO_PROXY` as the + * SDK does. Call it before any request is made. The proxy URL is never + * logged, since it may contain credentials. + */ +export function applyProxyFromEnv(env: NodeJS.ProcessEnv = process.env): ProxySetupResult { + const proxy = readEnvironment(env, PROXY_VARIABLES); + if (!proxy) { + return { status: 'none' }; + } + + const noProxy = parseNoProxy(readEnvironment(env, ['NO_PROXY'])?.value); + const { dispatcher, close } = createProxyDispatcher(proxy.value, noProxy); + const previous = getGlobalDispatcher(); + setGlobalDispatcher(dispatcher); + logger.debug(`Sending requests through the proxy set in ${proxy.name}`); + return { + status: 'applied', + restore: async () => { + setGlobalDispatcher(previous); + await close(); + }, + }; +} diff --git a/tests/unit/cli/index.test.ts b/tests/unit/cli/index.test.ts index ad5ab297..dfb6c0e0 100644 --- a/tests/unit/cli/index.test.ts +++ b/tests/unit/cli/index.test.ts @@ -10,11 +10,14 @@ const exec = promisify(execFile); const cliPath = path.resolve('src/cli/index.ts'); /** Run CLI via node --import tsx and capture stdout/stderr. */ -async function runCli(args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> { +async function runCli( + args: string[], + env: NodeJS.ProcessEnv = process.env, +): Promise<{ stdout: string; stderr: string; exitCode: number }> { try { const result = await exec(process.execPath, ['--import', 'tsx', cliPath, ...args], { timeout: 15000, - env: { ...process.env, NODE_ENV: 'test' }, + env: { ...env, NODE_ENV: 'test' }, }); return { stdout: result.stdout, stderr: result.stderr, exitCode: 0 }; } catch (error) { @@ -107,4 +110,26 @@ describe('CLI entry point', () => { expect(result.exitCode).toBe(0); }); }); + + describe('proxy setup', () => { + /** process.env without proxy or subscription variables, so extract stops before any request. */ + function cleanEnv(): NodeJS.ProcessEnv { + const env = { ...process.env }; + for (const name of Object.keys(env)) { + if (/^(https?_proxy|all_proxy|no_proxy|node_use_env_proxy|azure_subscription_id)$/i.test(name)) { + delete env[name]; + } + } + return env; + } + const extractArgs = ['extract', '--log-level', 'debug', '--resource-group', 'rg', '--service-name', 'svc']; + it('should apply HTTPS_PROXY before the command runs', async () => { + const result = await runCli(extractArgs, { ...cleanEnv(), HTTPS_PROXY: 'http://user:secret@127.0.0.1:9' }); + const output = result.stdout + result.stderr; + + expect(output).toContain('Sending requests through the proxy set in HTTPS_PROXY'); + expect(output).toContain('Subscription ID required'); + expect(output).not.toContain('secret'); + }, 15_000); + }); }); diff --git a/tests/unit/lib/proxy.test.ts b/tests/unit/lib/proxy.test.ts new file mode 100644 index 00000000..9ba59a42 --- /dev/null +++ b/tests/unit/lib/proxy.test.ts @@ -0,0 +1,188 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +/** + * Unit tests for applyProxyFromEnv: requests are sent with the real global + * fetch to local servers, so they show which proxy (if any) each request + * used, by the Azure SDK's proxy rules. + */ + +import * as http from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getGlobalDispatcher } from 'undici'; +import { logger } from '../../../src/lib/logger.js'; +import { applyProxyFromEnv, isProxyBypassed, parseNoProxy, type ProxySetupResult } from '../../../src/lib/proxy.js'; + +describe('parseNoProxy', () => { + it('should split on commas, trim entries and drop empty ones', () => { + expect(parseNoProxy(' a.example.com, .b.example.com ,, ')).toEqual(['a.example.com', '.b.example.com']); + expect(parseNoProxy(undefined)).toEqual([]); + }); +}); + +describe('isProxyBypassed', () => { + it.each([ + ['an exact host', 'management.azure.com', ['management.azure.com'], true], + ['a leading-dot domain, for a subdomain', 'management.azure.com', ['.azure.com'], true], + ['a leading-dot domain, for the domain itself', 'azure.com', ['.azure.com'], true], + ['a bare domain, for a subdomain', 'management.azure.com', ['azure.com'], false], + ['a wildcard', 'management.azure.com', ['*'], false], + ['a "*." pattern', 'management.azure.com', ['*.azure.com'], false], + ['an entry with a port', 'management.azure.com', ['management.azure.com:443'], false], + ['an entry in another case', 'management.azure.com', ['Management.Azure.com'], false], + ['an empty list', 'management.azure.com', [], false], + ])('should match %s as the Azure SDK does', (_label, host, noProxy, expected) => { + expect(isProxyBypassed(host, noProxy)).toBe(expected); + }); +}); + +/** A local HTTP server that answers every request with `name`. */ +async function startTarget(name: string): Promise { + const server = http.createServer((_request, response) => response.end(name)); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + return server; +} + +interface ProxyHit { + proxy: string; + target: string; + authorization?: string; +} + +/** + * Local proxies that answer forwarded HTTP requests with their name and + * refuse CONNECT tunnels, recording each request they receive. + */ +async function startProxies(names: string[]) { + const hits: ProxyHit[] = []; + const servers = await Promise.all( + names.map(async (name) => { + const server = http.createServer((request, response) => { + hits.push({ proxy: name, target: request.url ?? '', authorization: request.headers['proxy-authorization'] }); + response.end(`via ${name}`); + }); + server.on('connect', (request, socket) => { + hits.push({ proxy: name, target: request.url ?? '', authorization: request.headers['proxy-authorization'] }); + socket.end('HTTP/1.1 403 Forbidden\r\n\r\n'); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + return server; + }), + ); + const urls = servers.map((server) => `http://127.0.0.1:${(server.address() as AddressInfo).port}`); + return { hits, urls, servers }; +} + +function close(server: http.Server): Promise { + server.closeAllConnections(); + return new Promise((resolve) => server.close(() => resolve())); +} + +describe('applyProxyFromEnv', () => { + let target: http.Server; + let targetUrl: string; + let proxies: Awaited>; + let result: ProxySetupResult | undefined; + + beforeEach(async () => { + vi.spyOn(logger, 'debug').mockImplementation(() => undefined); + target = await startTarget('direct'); + // "localhost" so NO_PROXY entries can name the target's host. + targetUrl = `http://localhost:${(target.address() as AddressInfo).port}/`; + proxies = await startProxies(['A', 'B']); + result = undefined; + }); + + afterEach(async () => { + if (result?.status === 'applied') { + await result.restore(); + } + await Promise.all([target, ...proxies.servers].map(close)); + vi.restoreAllMocks(); + }); + + async function fetchTarget(): Promise { + return (await fetch(targetUrl)).text(); + } + + it('should change nothing when no proxy variable is set', async () => { + const before = getGlobalDispatcher(); + + result = applyProxyFromEnv({ NO_PROXY: 'localhost' }); + + expect(result).toEqual({ status: 'none' }); + expect(getGlobalDispatcher()).toBe(before); + expect(await fetchTarget()).toBe('direct'); + }); + + it.each(['HTTPS_PROXY', 'all_proxy', 'http_proxy'])( + 'should send every request through the proxy when only %s is set', + async (name) => { + result = applyProxyFromEnv({ [name]: proxies.urls[0] }); + + expect(await fetchTarget()).toBe('via A'); + expect(proxies.hits).toEqual([{ proxy: 'A', target: targetUrl, authorization: undefined }]); + }, + ); + + it.each([ + ['HTTPS_PROXY over ALL_PROXY', { HTTPS_PROXY: 0, ALL_PROXY: 1 }], + ['ALL_PROXY over HTTP_PROXY', { ALL_PROXY: 0, HTTP_PROXY: 1 }], + ['HTTPS_PROXY over https_proxy', { https_proxy: 1, HTTPS_PROXY: 0 }], + ['https_proxy over ALL_PROXY', { ALL_PROXY: 1, https_proxy: 0 }], + ])('should prefer %s, as the Azure SDK does', async (_label, picks) => { + const env = Object.fromEntries(Object.entries(picks).map(([name, i]) => [name, proxies.urls[i]])); + + result = applyProxyFromEnv(env); + + expect(await fetchTarget()).toBe('via A'); + }); + + it.each([ + ['the exact host', 'localhost', 'direct'], + ['a wildcard, which the SDK does not support', '*', 'via A'], + ])('should apply NO_PROXY set to %s as the Azure SDK does', async (_label, noProxy, expected) => { + result = applyProxyFromEnv({ HTTPS_PROXY: proxies.urls[0], NO_PROXY: noProxy }); + + expect(await fetchTarget()).toBe(expected); + }); + + it('should prefer NO_PROXY over no_proxy, as the Azure SDK does', async () => { + result = applyProxyFromEnv({ HTTPS_PROXY: proxies.urls[0], NO_PROXY: 'localhost', no_proxy: 'example.com' }); + + expect(await fetchTarget()).toBe('direct'); + }); + + it.each([ + ['a user name and password', 'user:pa%40ss@', `Basic ${Buffer.from('user:pa@ss').toString('base64')}`], + ['a password only', ':secret@', `Basic ${Buffer.from(':secret').toString('base64')}`], + ])('should send %s from the proxy URL as the Azure SDK does', async (_label, credentials, expected) => { + result = applyProxyFromEnv({ HTTPS_PROXY: proxies.urls[0].replace('http://', `http://${credentials}`) }); + + await fetchTarget(); + + expect(proxies.hits[0]?.authorization).toBe(expected); + expect(JSON.stringify(vi.mocked(logger.debug).mock.calls)).not.toContain('secret'); + }); + + it('should tunnel HTTPS requests through the proxy', async () => { + result = applyProxyFromEnv({ HTTPS_PROXY: proxies.urls[0] }); + + await expect(fetch('https://management.azure.com/')).rejects.toThrow(); + + expect(proxies.hits).toEqual([{ proxy: 'A', target: 'management.azure.com:443', authorization: undefined }]); + }); + + it('should put the previous dispatcher back on restore', async () => { + const before = getGlobalDispatcher(); + const applied = applyProxyFromEnv({ HTTPS_PROXY: proxies.urls[0] }); + + expect(applied.status).toBe('applied'); + if (applied.status === 'applied') { + await applied.restore(); + } + + expect(getGlobalDispatcher()).toBe(before); + expect(await fetchTarget()).toBe('direct'); + }); +}); From 74371b005cbd1b6c8cb941b9257d5f3945280ba8 Mon Sep 17 00:00:00 2001 From: Chris Dayne <1642847+cdayne@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:13:51 +1100 Subject: [PATCH 02/13] fix: use the target cloud's ARM endpoint in the Key Vault pre-flight check The Key Vault access check hard-coded https://management.azure.com for both its token scope and every ARM request, so publishing Key Vault-backed named values with --cloud china|usgov|germany queried the public cloud. KeyVaultCheckContext now carries armBaseUrl and authScope. publish-command passes the cloud resolved from --cloud in PublishConfig.cloud, and the resource publisher hands its ARM endpoint and scope to the check, so it targets the same cloud as the APIM client. Soft-skip and warning behaviour is unchanged. Secret URI parsing already used only the first host label and now has tests for sovereign vault suffixes. --- src/cli/publish-command.ts | 1 + src/models/config.ts | 7 + src/services/keyvault-checker.ts | 35 +++-- src/services/resource-publisher.ts | 3 + tests/unit/cli/publish-command.test.ts | 40 ++++++ tests/unit/services/keyvault-checker.test.ts | 124 +++++++++++++++++- .../unit/services/resource-publisher.test.ts | 98 +++++++++++++- 7 files changed, 292 insertions(+), 16 deletions(-) diff --git a/src/cli/publish-command.ts b/src/cli/publish-command.ts index 16a94abf..fc890590 100644 --- a/src/cli/publish-command.ts +++ b/src/cli/publish-command.ts @@ -151,6 +151,7 @@ async function executePublish( // Build publish config const publishConfig: PublishConfig = { service: context, + cloud: cloudConfig, sourceDir: options.source, filter: filterConfig, includeTransitive: options.transitive, diff --git a/src/models/config.ts b/src/models/config.ts index 1498eef3..2e7398f0 100644 --- a/src/models/config.ts +++ b/src/models/config.ts @@ -7,6 +7,7 @@ import { ApimServiceContext } from './types.js'; import { LogLevel } from '../lib/logger.js'; +import type { CloudConfig } from '../lib/cloud-config.js'; import type { EnvMapping } from '../services/env-mapper.js'; export interface ExtractConfig { @@ -88,6 +89,12 @@ export interface KnownArtifactSets { export interface PublishConfig { service: ApimServiceContext; + /** + * ARM endpoint and token scope of the target cloud, as resolved from + * `--cloud`. Used for ARM calls made outside the APIM client (the Key + * Vault pre-flight check). + */ + cloud: CloudConfig; sourceDir: string; filter?: FilterConfig; includeTransitive?: boolean; diff --git a/src/services/keyvault-checker.ts b/src/services/keyvault-checker.ts index 4497be18..271ee451 100644 --- a/src/services/keyvault-checker.ts +++ b/src/services/keyvault-checker.ts @@ -27,9 +27,6 @@ const RESOURCES_API_VERSION = '2021-04-01'; const KEYVAULT_API_VERSION = '2023-07-01'; const AUTHZ_API_VERSION = '2022-04-01'; -/** ARM management scope */ -const ARM_SCOPE = 'https://management.azure.com/.default'; - /** * Well-known Azure RBAC role-definition GUIDs that grant * the `Microsoft.KeyVault/vaults/secrets/getSecret/action` data-action. @@ -63,6 +60,13 @@ export interface KeyVaultCheckContext { subscriptionId: string; resourceGroup: string; serviceName: string; + /** + * ARM base URL of the target cloud, without a trailing slash + * (e.g. `https://management.usgovcloudapi.net`). + */ + armBaseUrl: string; + /** OAuth2 scope for ARM token requests in the target cloud. */ + authScope: string; } /** Minimal response shape from an ARM HTTP call. */ @@ -111,11 +115,13 @@ async function defaultArmRequest(url: string, token: string): Promise { const filter = encodeURIComponent(`principalId eq '${principalId}'`); const url = - `https://management.azure.com${vaultResourceId}` + + `${armBaseUrl}${vaultResourceId}` + `/providers/Microsoft.Authorization/roleAssignments` + `?$filter=${filter}&api-version=${AUTHZ_API_VERSION}`; @@ -465,7 +475,7 @@ async function checkRbacAccess( if (!roleDefId) continue; const hasAccess = await roleDefinitionGrantsSecretGet( - roleDefId, token, armRequest, + armBaseUrl, roleDefId, token, armRequest, ); if (hasAccess) return; // confirmed via dataActions } @@ -487,12 +497,13 @@ async function checkRbacAccess( * fetched — the caller should treat this as inconclusive and keep checking. */ async function roleDefinitionGrantsSecretGet( + armBaseUrl: string, roleDefinitionId: string, token: string, armRequest: ArmRequestFn, ): Promise { const url = - `https://management.azure.com${roleDefinitionId}` + + `${armBaseUrl}${roleDefinitionId}` + `?api-version=${AUTHZ_API_VERSION}`; let json: Record; diff --git a/src/services/resource-publisher.ts b/src/services/resource-publisher.ts index fe9f3729..2fc004b0 100644 --- a/src/services/resource-publisher.ts +++ b/src/services/resource-publisher.ts @@ -381,10 +381,13 @@ export async function publishResource( const secretIdentifier = kvBlock.secretIdentifier as string | undefined; const identityClientId = kvBlock.identityClientId as string | undefined; if (secretIdentifier) { + // Target the same cloud as the APIM client (resolved from --cloud). await checkKeyVaultSecretAccess(secretIdentifier, identityClientId, { subscriptionId: context.subscriptionId, resourceGroup: context.resourceGroup, serviceName: context.serviceName, + armBaseUrl: config.cloud.armBaseUrl, + authScope: config.cloud.authScope, }); } } diff --git a/tests/unit/cli/publish-command.test.ts b/tests/unit/cli/publish-command.test.ts index 67d5527e..be537d22 100644 --- a/tests/unit/cli/publish-command.test.ts +++ b/tests/unit/cli/publish-command.test.ts @@ -5,12 +5,24 @@ */ import { describe, it, expect, vi } from 'vitest'; +import { Command } from 'commander'; import { createPublishCommand, hasMutuallyExclusivePublishOptions, outputText, } from '../../../src/cli/publish-command.js'; import { PublishResult } from '../../../src/services/publish-service.js'; +import type { PublishConfig } from '../../../src/models/config.js'; + +const runPublishMock = vi.hoisted(() => vi.fn()); + +vi.mock('../../../src/services/publish-service.js', () => ({ + runPublish: runPublishMock, +})); + +vi.mock('../../../src/clients/apim-client.js', () => ({ + ApimClient: class {}, +})); describe('publish-command', () => { describe('createPublishCommand', () => { @@ -101,6 +113,34 @@ describe('publish-command', () => { }); }); + describe('cloud resolution', () => { + it('should pass the --cloud ARM endpoint and auth scope to the publish config', async () => { + // Stop after the config is built, before output and process.exit. + runPublishMock.mockRejectedValueOnce(new Error('stop after runPublish')); + const program = new Command() + .option('--subscription-id ') + .option('--cloud ', 'cloud', 'public') + .addCommand(createPublishCommand()); + + await expect( + program.parseAsync( + [ + '--subscription-id', 'sub-1', '--cloud', 'usgov', + 'publish', '--resource-group', 'rg-1', '--service-name', 'apim-1', + ], + { from: 'user' } + ) + ).rejects.toThrow('stop after runPublish'); + + const config = runPublishMock.mock.calls[0]?.[2] as PublishConfig; + expect(config.cloud).toEqual({ + armBaseUrl: 'https://management.usgovcloudapi.net', + authScope: 'https://management.usgovcloudapi.net/.default', + }); + expect(config.service.baseUrl).toMatch(/^https:\/\/management\.usgovcloudapi\.net\//); + }); + }); + describe('configuration loading', () => { it('should pass dry-run flag to publish service', () => { const cmd = createPublishCommand(); diff --git a/tests/unit/services/keyvault-checker.test.ts b/tests/unit/services/keyvault-checker.test.ts index 4efb33b8..4d64f660 100644 --- a/tests/unit/services/keyvault-checker.test.ts +++ b/tests/unit/services/keyvault-checker.test.ts @@ -23,6 +23,8 @@ describe('checkKeyVaultSecretAccess', () => { subscriptionId: 'sub-1', resourceGroup: 'rg-1', serviceName: 'apim-1', + armBaseUrl: 'https://management.azure.com', + authScope: 'https://management.azure.com/.default', }; /** Build a minimal ARM response stub */ @@ -577,7 +579,127 @@ describe('checkKeyVaultSecretAccess', () => { }); }); +describe('checkKeyVaultSecretAccess in sovereign clouds', () => { + const VAULT_RESOURCE_ID = + '/subscriptions/sub-1/resourceGroups/vault-rg/providers/Microsoft.KeyVault/vaults/myvault'; + const CUSTOM_ROLE_DEFINITION_ID = + '/subscriptions/sub-1/providers/Microsoft.Authorization/roleDefinitions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; + + function armResponse(status: number, body: unknown) { + return { status, json: () => Promise.resolve(body) }; + } + + const clouds = [ + { + name: 'usgov', + armBaseUrl: 'https://management.usgovcloudapi.net', + authScope: 'https://management.usgovcloudapi.net/.default', + secretIdentifier: 'https://myvault.vault.usgovcloudapi.net/secrets/my-secret', + }, + { + name: 'china', + armBaseUrl: 'https://management.chinacloudapi.cn', + authScope: 'https://management.chinacloudapi.cn/.default', + secretIdentifier: 'https://myvault.vault.azure.cn/secrets/my-secret', + }, + ]; + + it.each(clouds)( + 'should use the $name ARM scope and endpoint for every request', + async ({ armBaseUrl, authScope, secretIdentifier }) => { + const getToken = vi.fn().mockResolvedValue({ token: 'fake-arm-token' }); + const armRequest = vi.fn() + .mockResolvedValueOnce(armResponse(200, { + identity: { type: 'SystemAssigned', principalId: 'principal-1' }, + })) + .mockResolvedValueOnce(armResponse(200, { value: [{ id: VAULT_RESOURCE_ID }] })) + .mockResolvedValueOnce(armResponse(200, { properties: { enableRbacAuthorization: true } })) + // Unknown role GUID forces the role-definition GET (slow path) + .mockResolvedValueOnce(armResponse(200, { + value: [{ properties: { roleDefinitionId: CUSTOM_ROLE_DEFINITION_ID } }], + })) + .mockResolvedValueOnce(armResponse(200, { + properties: { + permissions: [{ dataActions: ['Microsoft.KeyVault/vaults/secrets/getSecret/action'] }], + }, + })); + + await expect( + checkKeyVaultSecretAccess( + secretIdentifier, + undefined, + { + subscriptionId: 'sub-1', + resourceGroup: 'rg-1', + serviceName: 'apim-1', + armBaseUrl, + authScope, + }, + () => ({ getToken }), + armRequest, + ), + ).resolves.toBeUndefined(); + + expect(getToken).toHaveBeenCalledWith(authScope); + + const urls = armRequest.mock.calls.map((call) => call[0] as string); + expect(urls).toHaveLength(5); + for (const url of urls) { + expect(new URL(url).origin).toBe(armBaseUrl); + } + expect(urls[0]).toContain( + '/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.ApiManagement/service/apim-1?', + ); + expect(urls[1]).toContain('/subscriptions/sub-1/resources?'); + expect(urls[2]).toContain(`${armBaseUrl}${VAULT_RESOURCE_ID}?`); + expect(urls[3]).toContain( + `${armBaseUrl}${VAULT_RESOURCE_ID}/providers/Microsoft.Authorization/roleAssignments?`, + ); + expect(urls[4]).toContain(`${armBaseUrl}${CUSTOM_ROLE_DEFINITION_ID}?`); + }, + ); + + it.each([ + 'https://myvault.vault.usgovcloudapi.net/secrets/my-secret/abc123version', + 'https://myvault.vault.azure.cn/secrets/my-secret', + 'https://myvault.vault.microsoftazure.de/secrets/my-secret', + ])('should parse the vault name from sovereign-cloud secret URI %s', async (secretIdentifier) => { + const armRequest = vi.fn() + .mockResolvedValueOnce(armResponse(200, { + identity: { type: 'SystemAssigned', principalId: 'principal-1' }, + })) + // Vault not found → soft-skip after the lookup, which carries the parsed name + .mockResolvedValueOnce(armResponse(200, { value: [] })); + + await expect( + checkKeyVaultSecretAccess( + secretIdentifier, + undefined, + { + subscriptionId: 'sub-1', + resourceGroup: 'rg-1', + serviceName: 'apim-1', + armBaseUrl: 'https://management.usgovcloudapi.net', + authScope: 'https://management.usgovcloudapi.net/.default', + }, + () => ({ getToken: vi.fn().mockResolvedValue({ token: 'fake-arm-token' }) }), + armRequest, + ), + ).resolves.toBeUndefined(); + + const vaultSearchUrl = armRequest.mock.calls[1][0] as string; + expect(decodeURIComponent(vaultSearchUrl)).toContain("name eq 'myvault'"); + }); +}); + describe('defaultArmRequest User-Agent header', () => { + const apimContextForUserAgentTest: KeyVaultCheckContext = { + subscriptionId: 'sub-1', + resourceGroup: 'rg-1', + serviceName: 'apim-1', + armBaseUrl: 'https://management.azure.com', + authScope: 'https://management.azure.com/.default', + }; let fetchSpy: ReturnType; beforeEach(() => { @@ -620,7 +742,7 @@ describe('defaultArmRequest User-Agent header', () => { await checkKeyVaultSecretAccess( 'https://myvault.vault.azure.net/secrets/my-secret', undefined, - { subscriptionId: 'sub-1', resourceGroup: 'rg-1', serviceName: 'apim-1' }, + apimContextForUserAgentTest, mockTokenProviderFactory, // No armRequest override — uses defaultArmRequest which must include User-Agent ); diff --git a/tests/unit/services/resource-publisher.test.ts b/tests/unit/services/resource-publisher.test.ts index 8e1154e5..80716e27 100644 --- a/tests/unit/services/resource-publisher.test.ts +++ b/tests/unit/services/resource-publisher.test.ts @@ -17,10 +17,11 @@ import { ResourceType } from '../../../src/models/resource-types.js'; import { ApimServiceContext, ResourceDescriptor } from '../../../src/models/types.js'; import { PublishConfig } from '../../../src/models/config.js'; import { KeyVaultAccessError } from '../../../src/services/keyvault-checker.js'; -import { LogLevel } from '../../../src/lib/logger.js'; +import { LogLevel, logger } from '../../../src/lib/logger.js'; import { REDACTION_MARKER } from '../../../src/services/secret-redactor.js'; import { buildEnvMapping } from '../../../src/services/env-mapper.js'; import { HttpError } from '../../../src/clients/apim-client.js'; +import { getCloudConfig } from '../../../src/lib/cloud-config.js'; // Mock keyvault-checker so resource-publisher tests don't need an Azure environment const mockCheckKeyVaultSecretAccess = vi.fn().mockResolvedValue(undefined); @@ -70,6 +71,7 @@ const testContext: ApimServiceContext = { const testConfig: PublishConfig = { service: testContext, + cloud: getCloudConfig('public'), sourceDir: '/source', dryRun: false, deleteUnmatched: false, @@ -996,10 +998,94 @@ describe('resource-publisher', () => { expect(mockCheckKeyVaultSecretAccess).toHaveBeenCalledWith( 'https://myvault.vault.azure.net/secrets/my-secret', 'my-mi-client-id', - { subscriptionId: 'sub-1', resourceGroup: 'rg-1', serviceName: 'apim-1' } + { + subscriptionId: 'sub-1', + resourceGroup: 'rg-1', + serviceName: 'apim-1', + armBaseUrl: 'https://management.azure.com', + authScope: 'https://management.azure.com/.default', + } ); }); + it('should pass the sovereign-cloud ARM endpoint and scope from the publish config', async () => { + const client = createMockClient(); + const store = createMockStore(); + store.readResource.mockResolvedValue({ + name: 'kv-nv', + properties: { + secret: true, + keyVault: { + secretIdentifier: 'https://myvault.vault.usgovcloudapi.net/secrets/my-secret', + }, + }, + }); + const usGovContext: ApimServiceContext = { + ...testContext, + baseUrl: 'https://management.usgovcloudapi.net/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.ApiManagement/service/apim-1', + }; + + const descriptor: ResourceDescriptor = { + type: ResourceType.NamedValue, + nameParts: ['kv-nv'], + }; + + await publishResource(client, store, usGovContext, descriptor, { + ...testConfig, + service: usGovContext, + cloud: getCloudConfig('usgov'), + }); + + expect(mockCheckKeyVaultSecretAccess).toHaveBeenCalledWith( + 'https://myvault.vault.usgovcloudapi.net/secrets/my-secret', + undefined, + expect.objectContaining({ + armBaseUrl: 'https://management.usgovcloudapi.net', + authScope: 'https://management.usgovcloudapi.net/.default', + }) + ); + }); + + it.each([ + ['a host that is no known cloud\'s', 'https://arm.example.test'], + ['another cloud\'s ARM endpoint', 'https://management.azure.com'], + ])('should use the cloud from the publish config when baseUrl is on %s', async (_label, host) => { + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => undefined); + const client = createMockClient(); + const store = createMockStore(); + store.readResource.mockResolvedValue({ + name: 'kv-nv', + properties: { + secret: true, + keyVault: { secretIdentifier: 'https://myvault.vault.usgovcloudapi.net/secrets/my-secret' }, + }, + }); + const context: ApimServiceContext = { + ...testContext, + baseUrl: `${host}/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.ApiManagement/service/apim-1`, + }; + const usGov = { + armBaseUrl: 'https://management.usgovcloudapi.net', + authScope: 'https://management.usgovcloudapi.net/.default', + }; + const descriptor: ResourceDescriptor = { type: ResourceType.NamedValue, nameParts: ['kv-nv'] }; + + await publishResource(client, store, context, descriptor, { ...testConfig, service: context, cloud: usGov }); + + expect(mockCheckKeyVaultSecretAccess).toHaveBeenCalledWith( + 'https://myvault.vault.usgovcloudapi.net/secrets/my-secret', + undefined, + { + subscriptionId: 'sub-1', + resourceGroup: 'rg-1', + serviceName: 'apim-1', + ...usGov, + } + ); + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + it('should pass undefined identityClientId for system-assigned identity', async () => { const client = createMockClient(); const store = createMockStore(); @@ -1024,7 +1110,13 @@ describe('resource-publisher', () => { expect(mockCheckKeyVaultSecretAccess).toHaveBeenCalledWith( 'https://myvault.vault.azure.net/secrets/my-secret', undefined, - { subscriptionId: 'sub-1', resourceGroup: 'rg-1', serviceName: 'apim-1' } + { + subscriptionId: 'sub-1', + resourceGroup: 'rg-1', + serviceName: 'apim-1', + armBaseUrl: 'https://management.azure.com', + authScope: 'https://management.azure.com/.default', + } ); }); From e3a5b3d98b12385e58bfff703c6c4d6312b6fb16 Mon Sep 17 00:00:00 2001 From: Chris Dayne <1642847+cdayne@users.noreply.github.com> Date: Tue, 3 Nov 2026 20:18:04 +1100 Subject: [PATCH 03/13] fix: allow publish pre-flight without Reader on the resource group validatePreFlight read the resource group before the APIM service, so a principal scoped to the APIM service only got 403 on the resource group read and publish failed before the service check ran. A 403 on the resource group read is now logged at debug and the resource group check is skipped; the APIM service check still runs and decides. When the check was skipped and the service GET returns 404, the error names the subscription and resource group and asks to verify them. The 404 message for a missing resource group, 401 handling and all other errors are unchanged. Update the RBAC section of the CI/CD authentication guide: Reader on the resource group is no longer required. The generated identity guides and Copilot setup prompts say the same: Reader on the resource group is optional, and with it a missing resource group is reported clearly. Fixes #303 --- docs/ci-cd/authentication-patterns.md | 8 +- src/clients/apim-client.ts | 28 ++++- src/clients/iapim-client.ts | 2 + .../identity-setup-prompt-azure-devops.md | 2 +- .../identity-setup-prompt-github-actions.md | 4 +- .../copilot/identity-setup-prompt.ts | 2 +- .../identity/identity-guide-azure-devops.md | 2 +- .../identity/identity-guide-github-actions.md | 2 +- tests/unit/clients/apim-client.test.ts | 112 ++++++++++++++++++ tests/unit/services/publish-service.test.ts | 71 ++++++++++- 10 files changed, 216 insertions(+), 17 deletions(-) diff --git a/docs/ci-cd/authentication-patterns.md b/docs/ci-cd/authentication-patterns.md index 1b1ce973..726d35e5 100644 --- a/docs/ci-cd/authentication-patterns.md +++ b/docs/ci-cd/authentication-patterns.md @@ -305,7 +305,8 @@ apiops-cli needs these Azure RBAC roles on the APIM service instance: | Role | Scope | Purpose | |------|-------|---------| | **API Management Service Contributor** | APIM service resource | Read and write APIM resources (APIs, products, policies, etc.) | -| **Reader** | Resource group | List resources in the resource group | + +**Reader** on the resource group is not required. Before publishing, `apiops publish` checks that the resource group exists so it can report a missing resource group clearly. If the identity cannot read the resource group (HTTP 403), the CLI skips that check and checks the APIM service directly. ### Assign RBAC @@ -315,11 +316,6 @@ az role assignment create \ --assignee {principal-id} \ --role "API Management Service Contributor" \ --scope "/subscriptions/{sub-id}/resourceGroups/{rg}/providers/Microsoft.ApiManagement/service/{apim-name}" - -az role assignment create \ - --assignee {principal-id} \ - --role "Reader" \ - --scope "/subscriptions/{sub-id}/resourceGroups/{rg}" ``` > **Principle of least privilege:** Scope the Contributor role to the specific APIM instance, not the entire resource group or subscription. diff --git a/src/clients/apim-client.ts b/src/clients/apim-client.ts index 06adbf5e..a2abbe78 100644 --- a/src/clients/apim-client.ts +++ b/src/clients/apim-client.ts @@ -874,16 +874,27 @@ export class ApimClient implements IApimClient { } async validatePreFlight(context: ApimServiceContext): Promise { - // Check resource group exists + // Check resource group exists. This check only exists to give a clearer + // error for a missing resource group; publish itself needs no access to the + // resource group. A principal scoped to the APIM service only (no Reader on + // the resource group) gets 403 here, so skip the check and let the service + // check below decide. const armEndpoint = new URL(context.baseUrl).origin; const rgUrl = `${armEndpoint}/subscriptions/${encodeURIComponent(context.subscriptionId)}/resourceGroups/${encodeURIComponent(context.resourceGroup)}?api-version=${ApimClient.RESOURCE_GROUP_API_VERSION}`; - let rgResponse: Response; + let rgResponse: Response | undefined; try { rgResponse = await this.request(rgUrl, { method: 'GET' }); } catch (error) { - this.throwPreFlightHttpError(error, `resource group '${context.resourceGroup}'`, `the subscription or resource group`); + if (error instanceof HttpError && error.status === 403) { + logger.debug( + `No permission to read resource group '${context.resourceGroup}' (HTTP 403) — ` + + `skipping resource group pre-flight check and checking the APIM service directly.` + ); + } else { + this.throwPreFlightHttpError(error, `resource group '${context.resourceGroup}'`, `the subscription or resource group`); + } } - if (rgResponse.status === 404) { + if (rgResponse?.status === 404) { throw new Error( `Resource group '${context.resourceGroup}' not found in subscription '${context.subscriptionId}'. ` + `Ensure the resource group exists before publishing.` @@ -899,6 +910,15 @@ export class ApimClient implements IApimClient { this.throwPreFlightHttpError(error, `APIM service '${context.serviceName}'`, `the APIM resource`); } if (apimResponse.status === 404) { + if (!rgResponse) { + // The resource group check was skipped, so a 404 here can also mean + // the resource group (or subscription) is wrong. + throw new Error( + `APIM service '${context.serviceName}' not found in resource group '${context.resourceGroup}' ` + + `of subscription '${context.subscriptionId}'. ` + + `Ensure the subscription, resource group and APIM service name are correct and that the APIM instance exists before publishing.` + ); + } throw new Error( `APIM service '${context.serviceName}' not found in resource group '${context.resourceGroup}'. ` + `Ensure the APIM instance exists before publishing.` diff --git a/src/clients/iapim-client.ts b/src/clients/iapim-client.ts index c3638018..d84cab3e 100644 --- a/src/clients/iapim-client.ts +++ b/src/clients/iapim-client.ts @@ -101,6 +101,8 @@ export interface IApimClient { /** * Validate that the target resource group and APIM service instance exist. * Throws an error with a clear message if either is not found (404). + * The resource group check is skipped when the caller lacks permission to + * read the resource group (403); only the APIM service check is required. * Must be called before any publish operations to surface missing infrastructure early. */ validatePreFlight(context: ApimServiceContext): Promise; diff --git a/src/templates/copilot/identity-setup-prompt-azure-devops.md b/src/templates/copilot/identity-setup-prompt-azure-devops.md index 2c2bad90..cc8b7ef9 100644 --- a/src/templates/copilot/identity-setup-prompt-azure-devops.md +++ b/src/templates/copilot/identity-setup-prompt-azure-devops.md @@ -219,7 +219,7 @@ No client secret is required in this flow. ## Step 6: Assign RBAC Roles Per Environment -Grant the service principal **Reader** on each resource group and **API Management Service Contributor** on each APIM instance. +Grant the service principal **API Management Service Contributor** on each APIM instance. **Reader** on each resource group is optional: `apiops publish` doesn't need it, but with it a missing resource group is reported clearly; without it, publish skips that check and checks the APIM service directly. Leave out the `Reader` commands below to skip it. ### PowerShell ```powershell diff --git a/src/templates/copilot/identity-setup-prompt-github-actions.md b/src/templates/copilot/identity-setup-prompt-github-actions.md index 01b21ce1..b1162db6 100644 --- a/src/templates/copilot/identity-setup-prompt-github-actions.md +++ b/src/templates/copilot/identity-setup-prompt-github-actions.md @@ -141,12 +141,12 @@ Write-Host "Tenant ID: $(az account show --query tenantId -o tsv)" ## Step 3 — Assign RBAC Roles Grant the service principal the required permissions: -1. **Reader** role on each resource group (to read resource groups and resources) +1. **Reader** role on each resource group (optional: `apiops publish` doesn't need it, but with it a missing resource group is reported clearly; without it, publish skips that check and checks the APIM service directly) 2. **API Management Service Contributor** on each APIM instance (to manage APIM resources) > **Note:** Each environment can be in a different Azure subscription. The service principal will be granted access to all environments. -### Grant Reader role on each resource group +### Grant Reader role on each resource group (optional) {{ENV_READER_ROLE_SNIPPETS}} diff --git a/src/templates/copilot/identity-setup-prompt.ts b/src/templates/copilot/identity-setup-prompt.ts index 2ccc75ec..2c42ec9f 100644 --- a/src/templates/copilot/identity-setup-prompt.ts +++ b/src/templates/copilot/identity-setup-prompt.ts @@ -85,7 +85,7 @@ gh api --method PUT "repos/\${GITHUB_ORG}/\${GITHUB_REPO}/environments/${env}"` const envReaderRoleSnippets = config.environments.map((env) => `\`\`\`bash -# Reader role for ${env} resource group +# Optional: Reader role for ${env} resource group (a clearer message when it is missing) az role assignment create \\ --assignee "$APP_ID" \\ --role "Reader" \\ diff --git a/src/templates/identity/identity-guide-azure-devops.md b/src/templates/identity/identity-guide-azure-devops.md index 602b8120..17f88e60 100644 --- a/src/templates/identity/identity-guide-azure-devops.md +++ b/src/templates/identity/identity-guide-azure-devops.md @@ -46,7 +46,7 @@ Helpful documentation: For each environment, use the **Azure portal** to grant: -- **Reader** on the resource group that contains the APIM instance +- **Reader** on the resource group that contains the APIM instance (optional: `apiops publish` doesn't need it, but with it a missing resource group is reported clearly; without it, publish skips that check and checks the APIM service directly) - **API Management Service Contributor** on the APIM service itself Use **Access control (IAM)** on each resource group and APIM instance to create the assignments. diff --git a/src/templates/identity/identity-guide-github-actions.md b/src/templates/identity/identity-guide-github-actions.md index e80e2422..ed55791e 100644 --- a/src/templates/identity/identity-guide-github-actions.md +++ b/src/templates/identity/identity-guide-github-actions.md @@ -47,7 +47,7 @@ For each environment, do the following steps: 1. In the **Azure portal**, open the resource group that contains the APIM instance for that environment. 2. Go to **Access control (IAM)** → **Add role assignment**. -3. Assign **Reader** on the resource group to the app registration. +3. Optionally, assign **Reader** on the resource group to the app registration. `apiops publish` doesn't need it, but with it a missing resource group is reported clearly; without it, publish skips that check and checks the APIM service directly. 4. Open the APIM service itself and assign **API Management Service Contributor** to the app registration. 5. Wait a few minutes for RBAC changes to propagate before you test the workflow. diff --git a/tests/unit/clients/apim-client.test.ts b/tests/unit/clients/apim-client.test.ts index d09cb2b2..67b63767 100644 --- a/tests/unit/clients/apim-client.test.ts +++ b/tests/unit/clients/apim-client.test.ts @@ -72,6 +72,118 @@ describe('ApimClient.validatePreFlight', () => { expect.objectContaining({ method: 'GET' }), ); }); + + describe('resource group check', () => { + const rgUrl = 'https://management.azure.com/subscriptions/sub-1/resourceGroups/rg-1?api-version=2021-04-01'; + const serviceUrl = `${testContext.baseUrl}?api-version=${testContext.apiVersion}`; + const rgForbidden = { + error: { + code: 'AuthorizationFailed', + message: + "The client does not have authorization to perform action 'Microsoft.Resources/subscriptions/resourceGroups/read' " + + "over scope '/subscriptions/sub-1/resourceGroups/rg-1'", + }, + }; + + let client: ApimClient; + let fetchSpy: ReturnType; + + /** Stub fetch with separate responses for the resource group and APIM service GETs. */ + function stubResponses(rg: () => Response, service: () => Response): void { + fetchSpy.mockImplementation((url: string) => { + if (url === rgUrl) return Promise.resolve(rg()); + if (url === serviceUrl) return Promise.resolve(service()); + return Promise.reject(new Error(`Unexpected URL: ${url}`)); + }); + } + + beforeEach(() => { + client = new ApimClient(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.spyOn(client as any, 'getToken').mockResolvedValue('fake-token'); + fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + }); + + it('should pass when the resource group read is forbidden but the APIM service exists', async () => { + const debugSpy = vi.spyOn(logger, 'debug'); + stubResponses(() => makeResponse(403, rgForbidden), () => makeResponse(200, {})); + + await expect(client.validatePreFlight(testContext)).resolves.toBeUndefined(); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(fetchSpy).toHaveBeenNthCalledWith(2, serviceUrl, expect.objectContaining({ method: 'GET' })); + expect(debugSpy).toHaveBeenCalledWith( + expect.stringContaining("No permission to read resource group 'rg-1' (HTTP 403)"), + ); + }); + + it('should not warn when the resource group read is forbidden', async () => { + const warnSpy = vi.spyOn(logger, 'warn'); + stubResponses(() => makeResponse(403, rgForbidden), () => makeResponse(200, {})); + + await client.validatePreFlight(testContext); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('should report a possibly wrong resource group when the resource group read is forbidden and the APIM service is not found', async () => { + stubResponses(() => makeResponse(403, rgForbidden), () => makeResponse(404, { error: { code: 'ResourceNotFound' } })); + + const error = await client.validatePreFlight(testContext).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe( + "APIM service 'apim-1' not found in resource group 'rg-1' of subscription 'sub-1'. " + + 'Ensure the subscription, resource group and APIM service name are correct and that the APIM instance exists before publishing.', + ); + }); + + it('should keep the permission error when the resource group read is forbidden and the APIM service read is forbidden', async () => { + stubResponses(() => makeResponse(403, rgForbidden), () => makeResponse(403, { error: { code: 'AuthorizationFailed' } })); + + await expect(client.validatePreFlight(testContext)).rejects.toThrow( + "Insufficient permissions to read APIM service 'apim-1'. " + + 'Ensure the service principal has at least Reader role on the APIM resource.', + ); + }); + + it('should report a missing resource group without checking the APIM service', async () => { + stubResponses(() => makeResponse(404, { error: { code: 'ResourceGroupNotFound' } }), () => makeResponse(200, {})); + + await expect(client.validatePreFlight(testContext)).rejects.toThrow( + "Resource group 'rg-1' not found in subscription 'sub-1'. Ensure the resource group exists before publishing.", + ); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('should report a missing APIM service when the resource group exists', async () => { + stubResponses(() => makeResponse(200, {}), () => makeResponse(404, { error: { code: 'ResourceNotFound' } })); + + await expect(client.validatePreFlight(testContext)).rejects.toThrow( + "APIM service 'apim-1' not found in resource group 'rg-1'. Ensure the APIM instance exists before publishing.", + ); + }); + + it('should fail with an authentication error when the resource group read returns 401', async () => { + stubResponses(() => makeResponse(401, { error: { code: 'InvalidAuthenticationToken' } }), () => makeResponse(200, {})); + + await expect(client.validatePreFlight(testContext)).rejects.toThrow( + "Authentication failed when checking resource group 'rg-1'.", + ); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('should rethrow other resource group errors unchanged', async () => { + stubResponses(() => makeResponse(400, { error: { code: 'InvalidSubscriptionId' } }), () => makeResponse(200, {})); + + const error = await client.validatePreFlight(testContext).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(HttpError); + expect((error as HttpError).status).toBe(400); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + }); }); describe('ApimClient.listResources', () => { diff --git a/tests/unit/services/publish-service.test.ts b/tests/unit/services/publish-service.test.ts index 6bcac061..9c0c2826 100644 --- a/tests/unit/services/publish-service.test.ts +++ b/tests/unit/services/publish-service.test.ts @@ -4,13 +4,14 @@ * Unit tests for Publish orchestration service */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { ResourceType } from '../../../src/models/resource-types.js'; import { ResourceDescriptor, ApimServiceContext } from '../../../src/models/types.js'; import { PublishConfig } from '../../../src/models/config.js'; import { LogLevel } from '../../../src/lib/logger.js'; import { recordRetry, withRetryResource } from '../../../src/lib/retry-tracker.js'; import { getResourceDescriptorKey } from '../../../src/lib/resource-path.js'; +import { ApimClient } from '../../../src/clients/apim-client.js'; // Mock service dependencies vi.mock('../../../src/services/git-diff-service.js'); @@ -1605,6 +1606,74 @@ describe('publish-service', () => { expect(client.putResource).not.toHaveBeenCalled(); }); + + describe('without Reader on the resource group', () => { + const rgUrl = 'https://management.azure.com/subscriptions/sub-1/resourceGroups/rg-1?api-version=2021-04-01'; + const serviceUrl = `${testContext.baseUrl}?api-version=${testContext.apiVersion}`; + const config: PublishConfig = { + service: testContext, + sourceDir: '/source', + dryRun: false, + deleteUnmatched: false, + logLevel: LogLevel.INFO, + }; + + /** + * Real ApimClient whose pre-flight GETs hit a stubbed fetch: the resource + * group read is forbidden and the APIM service read returns `serviceStatus`. + * Resource writes are stubbed on the instance so only pre-flight goes through fetch. + */ + function createClientWithForbiddenResourceGroup(serviceStatus: number) { + const client = new ApimClient(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.spyOn(client as any, 'getToken').mockResolvedValue('fake-token'); + vi.spyOn(client, 'getResource').mockResolvedValue(undefined); + vi.spyOn(client, 'putResource').mockResolvedValue(undefined); + vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string) => { + if (url === rgUrl) { + return Promise.resolve(new Response( + JSON.stringify({ error: { code: 'AuthorizationFailed', message: 'resourceGroups/read' } }), + { status: 403, headers: { 'Content-Type': 'application/json' } }, + )); + } + if (url === serviceUrl) { + return Promise.resolve(new Response('{}', { status: serviceStatus, headers: { 'Content-Type': 'application/json' } })); + } + return Promise.reject(new Error(`Unexpected URL: ${url}`)); + })); + return client; + } + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('should publish when the resource group read is forbidden but the APIM service exists', async () => { + const client = createClientWithForbiddenResourceGroup(200); + const store = createMockStore([{ type: ResourceType.Tag, nameParts: ['tag1'] }]); + + const result = await runPublish(client, store, config); + + expect(result.exitCode).toBe(0); + expect(client.putResource).toHaveBeenCalledWith( + testContext, + expect.objectContaining({ type: ResourceType.Tag, nameParts: ['tag1'] }), + expect.anything(), + ); + }); + + it('should fail pre-flight when the resource group read is forbidden and the APIM service is not found', async () => { + const client = createClientWithForbiddenResourceGroup(404); + const store = createMockStore([{ type: ResourceType.Tag, nameParts: ['tag1'] }]); + + const result = await runPublish(client, store, config); + + expect(result.exitCode).toBe(2); + expect(result.totalErrors).toBe(1); + expect(client.putResource).not.toHaveBeenCalled(); + }); + }); }); describe('named value ordering within tier 1', () => { From 1e1b8a7787ed548e4b130768988d84dddb87bef0 Mon Sep 17 00:00:00 2001 From: Chris Dayne <1642847+cdayne@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:14:25 +1100 Subject: [PATCH 04/13] fix: share publish pre-flight checks and skip forbidden resource group reads for API Center ApicClient.validatePreFlight read the resource group the same way the APIM client did before the previous commit, so `apiops apic publish` still failed for a principal without Reader on the resource group. Move the resource group and service checks into a shared validateServicePreFlight helper in src/clients/preflight.ts, used by both clients with their own wording. API Center now gets the same behaviour as APIM: a 403 on the resource group read is logged at debug and the service check decides, a skipped check widens the service 404 message, and 401/403 errors become actionable messages instead of raw HTTP errors. APIM messages are unchanged. API Center messages now match the APIM wording and quoting; the "Create it (Bicep/ARM)" hint is replaced by "Ensure the API Center service exists before publishing". Refs #303 --- src/clients/apic-client.ts | 32 ++--- src/clients/apim-client.ts | 86 ++----------- src/clients/preflight.ts | 132 ++++++++++++++++++++ tests/unit/clients/apic-client.test.ts | 161 +++++++++++++++++++++++++ 4 files changed, 315 insertions(+), 96 deletions(-) create mode 100644 src/clients/preflight.ts create mode 100644 tests/unit/clients/apic-client.test.ts diff --git a/src/clients/apic-client.ts b/src/clients/apic-client.ts index e940b3d5..d51e9138 100644 --- a/src/clients/apic-client.ts +++ b/src/clients/apic-client.ts @@ -20,6 +20,7 @@ import { } from '../lib/apic-uri.js'; import { logger } from '../lib/logger.js'; import { USER_AGENT } from '../lib/user-agent.js'; +import { validateServicePreFlight, PreFlightServiceKind } from './preflight.js'; /** HTTP error carrying the response status code and optional ARM error code. */ export class ApicHttpError extends Error { @@ -37,6 +38,13 @@ export class ApicHttpError extends Error { const TERMINAL_SUCCESS = 'Succeeded'; const TERMINAL_FAILURES = new Set(['Failed', 'Canceled']); +/** Pre-flight wording for API Center services. */ +const APIC_PREFLIGHT_KIND: PreFlightServiceKind = { + label: 'API Center service', + instance: 'API Center service', + roleTarget: 'the API Center resource', +}; + export class ApicClient implements IApicClient { private readonly credential: DefaultAzureCredential; private readonly authScope: string; @@ -51,7 +59,6 @@ export class ApicClient implements IApicClient { private static readonly ASYNC_POLL_TIMEOUT_MS = 10 * 60 * 1000; /** Default interval between LRO polls when no Retry-After header is present. */ private static readonly ASYNC_POLL_INTERVAL_MS = 5000; - private static readonly RESOURCE_GROUP_API_VERSION = '2021-04-01'; constructor(authScope = 'https://management.azure.com/.default') { this.credential = new DefaultAzureCredential(); @@ -341,26 +348,9 @@ export class ApicClient implements IApicClient { } async validatePreFlight(context: ApicServiceContext): Promise { - const cloudRoot = context.baseUrl.split('/subscriptions/')[0]; - const rgUrl = - `${cloudRoot}/subscriptions/${encodeURIComponent(context.subscriptionId)}` + - `/resourceGroups/${encodeURIComponent(context.resourceGroup)}` + - `?api-version=${ApicClient.RESOURCE_GROUP_API_VERSION}`; - const rgResponse = await this.request(rgUrl, { method: 'GET' }); - if (rgResponse.status === 404) { - throw new Error( - `Resource group "${context.resourceGroup}" not found in subscription ${context.subscriptionId}.`, - ); - } - - const serviceUrl = `${context.baseUrl}?api-version=${context.apiVersion}`; - const svcResponse = await this.request(serviceUrl, { method: 'GET' }); - if (svcResponse.status === 404) { - throw new Error( - `API Center service "${context.serviceName}" not found in resource group ` + - `"${context.resourceGroup}". Create it (Bicep/ARM) before publishing.`, - ); - } + await validateServicePreFlight(context, APIC_PREFLIGHT_KIND, (url) => + this.request(url, { method: 'GET' }), + ); } /** diff --git a/src/clients/apim-client.ts b/src/clients/apim-client.ts index a2abbe78..27a7d7cf 100644 --- a/src/clients/apim-client.ts +++ b/src/clients/apim-client.ts @@ -16,6 +16,7 @@ import { logger } from '../lib/logger.js'; import { isWorkspaceScope } from '../lib/workspace-link.js'; import { USER_AGENT } from '../lib/user-agent.js'; import { formatDuration } from '../lib/format-duration.js'; +import { validateServicePreFlight, PreFlightServiceKind } from './preflight.js'; import { recordRetry, withRetryResource } from '../lib/retry-tracker.js'; /** @@ -44,6 +45,13 @@ export function isLinkAlreadyExistsError(error: unknown): boolean { return error instanceof HttpError && error.status === 409; } +/** Pre-flight wording for APIM services. */ +const APIM_PREFLIGHT_KIND: PreFlightServiceKind = { + label: 'APIM service', + instance: 'APIM instance', + roleTarget: 'the APIM resource', +}; + /** Returns true when APIM reports that an association's referenced API/group is absent. */ export function isAssociationReferenceNotFoundError(error: unknown): boolean { if ( @@ -137,9 +145,6 @@ export class ApimClient implements IApimClient { 'management.usgovcloudapi.net', 'management.microsoftazure.de', ]; - /** Stable ARM API version for Resource Group existence checks */ - private static readonly RESOURCE_GROUP_API_VERSION = '2021-04-01'; - /** * @param authScope OAuth2 scope for ARM token requests. * Defaults to the Azure public cloud scope. @@ -874,78 +879,9 @@ export class ApimClient implements IApimClient { } async validatePreFlight(context: ApimServiceContext): Promise { - // Check resource group exists. This check only exists to give a clearer - // error for a missing resource group; publish itself needs no access to the - // resource group. A principal scoped to the APIM service only (no Reader on - // the resource group) gets 403 here, so skip the check and let the service - // check below decide. - const armEndpoint = new URL(context.baseUrl).origin; - const rgUrl = `${armEndpoint}/subscriptions/${encodeURIComponent(context.subscriptionId)}/resourceGroups/${encodeURIComponent(context.resourceGroup)}?api-version=${ApimClient.RESOURCE_GROUP_API_VERSION}`; - let rgResponse: Response | undefined; - try { - rgResponse = await this.request(rgUrl, { method: 'GET' }); - } catch (error) { - if (error instanceof HttpError && error.status === 403) { - logger.debug( - `No permission to read resource group '${context.resourceGroup}' (HTTP 403) — ` + - `skipping resource group pre-flight check and checking the APIM service directly.` - ); - } else { - this.throwPreFlightHttpError(error, `resource group '${context.resourceGroup}'`, `the subscription or resource group`); - } - } - if (rgResponse?.status === 404) { - throw new Error( - `Resource group '${context.resourceGroup}' not found in subscription '${context.subscriptionId}'. ` + - `Ensure the resource group exists before publishing.` - ); - } - - // Check APIM service instance exists - const apimUrl = `${context.baseUrl}?api-version=${context.apiVersion}`; - let apimResponse: Response; - try { - apimResponse = await this.request(apimUrl, { method: 'GET' }); - } catch (error) { - this.throwPreFlightHttpError(error, `APIM service '${context.serviceName}'`, `the APIM resource`); - } - if (apimResponse.status === 404) { - if (!rgResponse) { - // The resource group check was skipped, so a 404 here can also mean - // the resource group (or subscription) is wrong. - throw new Error( - `APIM service '${context.serviceName}' not found in resource group '${context.resourceGroup}' ` + - `of subscription '${context.subscriptionId}'. ` + - `Ensure the subscription, resource group and APIM service name are correct and that the APIM instance exists before publishing.` - ); - } - throw new Error( - `APIM service '${context.serviceName}' not found in resource group '${context.resourceGroup}'. ` + - `Ensure the APIM instance exists before publishing.` - ); - } - } - - /** - * Rethrows HttpError 401/403 responses from pre-flight checks as actionable errors. - * Non-HTTP errors and other status codes are re-thrown unchanged. - */ - private throwPreFlightHttpError(error: unknown, resourceLabel: string, roleTarget: string): never { - if (error instanceof HttpError && error.status === 403) { - throw new Error( - `Insufficient permissions to read ${resourceLabel}. ` + - `Ensure the service principal has at least Reader role on ${roleTarget}.`, - { cause: error } - ); - } - if (error instanceof HttpError && error.status === 401) { - throw new Error( - `Authentication failed when checking ${resourceLabel}. ` + - `Verify that credentials are valid and the correct tenant/subscription is targeted.`, - { cause: error } - ); - } - throw error as Error; + await validateServicePreFlight(context, APIM_PREFLIGHT_KIND, (url) => + this.request(url, { method: 'GET' }) + ); } private detectSpecificationFormat( diff --git a/src/clients/preflight.ts b/src/clients/preflight.ts new file mode 100644 index 00000000..66b19ee8 --- /dev/null +++ b/src/clients/preflight.ts @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +/** + * Publish pre-flight checks shared by the APIM and API Center clients. + * + * Both clients confirm that the resource group and the target service exist + * before publishing so that a wrong name fails fast with a clear message. The + * resource group check only improves that message; publish itself needs no + * access to the resource group, so a principal scoped to the service only + * (no Reader on the resource group) skips it and the service check decides. + */ + +import { logger } from '../lib/logger.js'; + +/** ARM api-version for the resource group GET. */ +const RESOURCE_GROUP_API_VERSION = '2021-04-01'; + +/** The service fields the pre-flight check needs (APIM or API Center context). */ +export interface PreFlightContext { + readonly subscriptionId: string; + readonly resourceGroup: string; + readonly serviceName: string; + readonly apiVersion: string; + /** Fully-qualified service base URL (`https:///subscriptions/.../{name}`). */ + readonly baseUrl: string; +} + +/** Wording that differs between the services, e.g. "APIM service" / "APIM instance". */ +export interface PreFlightServiceKind { + /** Service noun used in messages, e.g. `APIM service`. */ + readonly label: string; + /** Instance noun used in "Ensure the ... exists", e.g. `APIM instance`. */ + readonly instance: string; + /** Role assignment target named in permission errors, e.g. `the APIM resource`. */ + readonly roleTarget: string; +} + +/** + * Issue a GET and return the response. Must return 404 responses and throw + * an error carrying a numeric `status` for other non-2xx responses. + */ +export type PreFlightGet = (url: string) => Promise; + +/** + * Check that the resource group and service exist. + * + * A 403 on the resource group read skips that check (logged at debug); the + * service check still runs, and its 404 message then also names the + * subscription and resource group as possibly wrong. + */ +export async function validateServicePreFlight( + context: PreFlightContext, + kind: PreFlightServiceKind, + get: PreFlightGet +): Promise { + const armEndpoint = new URL(context.baseUrl).origin; + const rgUrl = + `${armEndpoint}/subscriptions/${encodeURIComponent(context.subscriptionId)}` + + `/resourceGroups/${encodeURIComponent(context.resourceGroup)}` + + `?api-version=${RESOURCE_GROUP_API_VERSION}`; + let rgResponse: Response | undefined; + try { + rgResponse = await get(rgUrl); + } catch (error) { + if (getHttpStatus(error) === 403) { + logger.debug( + `No permission to read resource group '${context.resourceGroup}' (HTTP 403) — ` + + `skipping resource group pre-flight check and checking the ${kind.label} directly.` + ); + } else { + throwPreFlightHttpError(error, `resource group '${context.resourceGroup}'`, 'the subscription or resource group'); + } + } + if (rgResponse?.status === 404) { + throw new Error( + `Resource group '${context.resourceGroup}' not found in subscription '${context.subscriptionId}'. ` + + `Ensure the resource group exists before publishing.` + ); + } + + const serviceLabel = `${kind.label} '${context.serviceName}'`; + let serviceResponse: Response; + try { + serviceResponse = await get(`${context.baseUrl}?api-version=${context.apiVersion}`); + } catch (error) { + throwPreFlightHttpError(error, serviceLabel, kind.roleTarget); + } + if (serviceResponse.status === 404) { + if (!rgResponse) { + // The resource group check was skipped, so a 404 here can also mean + // the resource group (or subscription) is wrong. + throw new Error( + `${serviceLabel} not found in resource group '${context.resourceGroup}' ` + + `of subscription '${context.subscriptionId}'. ` + + `Ensure the subscription, resource group and ${kind.label} name are correct and that the ${kind.instance} exists before publishing.` + ); + } + throw new Error( + `${serviceLabel} not found in resource group '${context.resourceGroup}'. ` + + `Ensure the ${kind.instance} exists before publishing.` + ); + } +} + +/** + * Rethrows HTTP 401/403 errors from pre-flight checks as actionable errors. + * Non-HTTP errors and other status codes are re-thrown unchanged. + */ +function throwPreFlightHttpError(error: unknown, resourceLabel: string, roleTarget: string): never { + const status = getHttpStatus(error); + if (status === 403) { + throw new Error( + `Insufficient permissions to read ${resourceLabel}. ` + + `Ensure the service principal has at least Reader role on ${roleTarget}.`, + { cause: error } + ); + } + if (status === 401) { + throw new Error( + `Authentication failed when checking ${resourceLabel}. ` + + `Verify that credentials are valid and the correct tenant/subscription is targeted.`, + { cause: error } + ); + } + throw error as Error; +} + +/** Status code of an HTTP error thrown by either client (`HttpError`, `ApicHttpError`). */ +function getHttpStatus(error: unknown): number | undefined { + const status = error instanceof Error ? (error as { status?: unknown }).status : undefined; + return typeof status === 'number' ? status : undefined; +} diff --git a/tests/unit/clients/apic-client.test.ts b/tests/unit/clients/apic-client.test.ts new file mode 100644 index 00000000..62492138 --- /dev/null +++ b/tests/unit/clients/apic-client.test.ts @@ -0,0 +1,161 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +/** + * Unit tests for ApicClient.validatePreFlight — resource group and API Center + * service existence checks, including principals without Reader on the + * resource group. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ApicClient, ApicHttpError } from '../../../src/clients/apic-client.js'; +import { ApicServiceContext } from '../../../src/models/apic-types.js'; +import { buildApicBaseUrl } from '../../../src/lib/apic-uri.js'; +import { logger } from '../../../src/lib/logger.js'; + +const testContext: ApicServiceContext = { + subscriptionId: 'sub-1', + resourceGroup: 'rg-1', + serviceName: 'apic-1', + apiVersion: '2024-06-01-preview', + baseUrl: buildApicBaseUrl('public', 'sub-1', 'rg-1', 'apic-1'), +}; + +function makeResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +describe('ApicClient.validatePreFlight', () => { + const rgUrl = 'https://management.azure.com/subscriptions/sub-1/resourceGroups/rg-1?api-version=2021-04-01'; + const serviceUrl = `${testContext.baseUrl}?api-version=${testContext.apiVersion}`; + const rgForbidden = { + error: { + code: 'AuthorizationFailed', + message: + "The client does not have authorization to perform action 'Microsoft.Resources/subscriptions/resourceGroups/read' " + + "over scope '/subscriptions/sub-1/resourceGroups/rg-1'", + }, + }; + + let client: ApicClient; + let fetchSpy: ReturnType; + + /** Stub fetch with separate responses for the resource group and API Center service GETs. */ + function stubResponses(rg: () => Response, service: () => Response): void { + fetchSpy.mockImplementation((url: string) => { + if (url === rgUrl) return Promise.resolve(rg()); + if (url === serviceUrl) return Promise.resolve(service()); + return Promise.reject(new Error(`Unexpected URL: ${url}`)); + }); + } + + beforeEach(() => { + client = new ApicClient(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.spyOn(client as any, 'getToken').mockResolvedValue('fake-token'); + fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it.each([ + ['public', 'https://management.azure.com'], + ['usgov', 'https://management.usgovcloudapi.net'], + ['china', 'https://management.chinacloudapi.cn'], + ])('should use the %s endpoint for both pre-flight checks', async (cloud, endpoint) => { + fetchSpy.mockImplementation(() => Promise.resolve(makeResponse(200, {}))); + const context: ApicServiceContext = { + ...testContext, + resourceGroup: 'rg with spaces', + baseUrl: buildApicBaseUrl(cloud, 'sub-1', 'rg with spaces', 'apic-1'), + }; + + await client.validatePreFlight(context); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(fetchSpy).toHaveBeenNthCalledWith( + 1, + `${endpoint}/subscriptions/sub-1/resourceGroups/rg%20with%20spaces?api-version=2021-04-01`, + expect.objectContaining({ method: 'GET' }), + ); + expect(fetchSpy).toHaveBeenNthCalledWith( + 2, + `${context.baseUrl}?api-version=${context.apiVersion}`, + expect.objectContaining({ method: 'GET' }), + ); + }); + + it('should pass when the resource group read is forbidden but the API Center service exists', async () => { + const debugSpy = vi.spyOn(logger, 'debug'); + const warnSpy = vi.spyOn(logger, 'warn'); + stubResponses(() => makeResponse(403, rgForbidden), () => makeResponse(200, {})); + + await expect(client.validatePreFlight(testContext)).resolves.toBeUndefined(); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(debugSpy).toHaveBeenCalledWith( + expect.stringContaining("No permission to read resource group 'rg-1' (HTTP 403)"), + ); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('should report a possibly wrong resource group when the resource group read is forbidden and the service is not found', async () => { + stubResponses(() => makeResponse(403, rgForbidden), () => makeResponse(404, { error: { code: 'ResourceNotFound' } })); + + await expect(client.validatePreFlight(testContext)).rejects.toThrow( + "API Center service 'apic-1' not found in resource group 'rg-1' of subscription 'sub-1'. " + + 'Ensure the subscription, resource group and API Center service name are correct and that the API Center service exists before publishing.', + ); + }); + + it('should report a permission error when the service read is forbidden', async () => { + stubResponses(() => makeResponse(403, rgForbidden), () => makeResponse(403, { error: { code: 'AuthorizationFailed' } })); + + await expect(client.validatePreFlight(testContext)).rejects.toThrow( + "Insufficient permissions to read API Center service 'apic-1'. " + + 'Ensure the service principal has at least Reader role on the API Center resource.', + ); + }); + + it('should report a missing resource group without checking the service', async () => { + stubResponses(() => makeResponse(404, { error: { code: 'ResourceGroupNotFound' } }), () => makeResponse(200, {})); + + await expect(client.validatePreFlight(testContext)).rejects.toThrow( + "Resource group 'rg-1' not found in subscription 'sub-1'. Ensure the resource group exists before publishing.", + ); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('should report a missing service when the resource group exists', async () => { + stubResponses(() => makeResponse(200, {}), () => makeResponse(404, { error: { code: 'ResourceNotFound' } })); + + await expect(client.validatePreFlight(testContext)).rejects.toThrow( + "API Center service 'apic-1' not found in resource group 'rg-1'. Ensure the API Center service exists before publishing.", + ); + }); + + it('should fail with an authentication error when the resource group read returns 401', async () => { + stubResponses(() => makeResponse(401, { error: { code: 'InvalidAuthenticationToken' } }), () => makeResponse(200, {})); + + await expect(client.validatePreFlight(testContext)).rejects.toThrow( + "Authentication failed when checking resource group 'rg-1'.", + ); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('should rethrow other resource group errors unchanged', async () => { + stubResponses(() => makeResponse(400, { error: { code: 'InvalidSubscriptionId' } }), () => makeResponse(200, {})); + + const error = await client.validatePreFlight(testContext).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(ApicHttpError); + expect((error as ApicHttpError).status).toBe(400); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); +}); From 7d46f624d0233057bb11301b81d636d94a2eb013 Mon Sep 17 00:00:00 2001 From: Chris Dayne <1642847+cdayne@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:42:44 +1100 Subject: [PATCH 05/13] test: cover every API Center ARM call ApicClient had tests only for validatePreFlight. Add characterisation tests for the rest of its ARM traffic to tests/unit/clients/apic-client.test.ts, grouped by area, before the client is hardened and refactored: - token: default and custom scope, caching across requests, one shared in-flight token request, refresh within five minutes of expiry - listResources: service, workspace and child collections with encoding, nextLink pagination, 404 as an empty list, 403 as ApicHttpError - getResource, putResource (200/201, 201 + Azure-AsyncOperation, 202 + Location), deleteResource (200/204/404, 202 + polling) - exportSpecification (inline, link without ARM credentials, link retry, 404s, failures) and importSpecification (body, 202 + polling, failure) - retry on 5xx and network errors, no retry on 4xx, 429 with numeric Retry-After and without it - LRO polling: in progress, Retry-After between polls, Failed/Canceled, 404 while polling a PUT, empty 200, ten-minute timeout `fetch` is stubbed, @azure/identity is mocked and timers are faked. Behaviours that are bugs (unvalidated LRO and nextLink hosts, the final 429, non-numeric Retry-After, case-sensitive LRO status, PUT 200 with Azure-AsyncOperation, DELETE 202 without a polling header) are not locked in here. --- tests/unit/clients/apic-client.test.ts | 839 ++++++++++++++++++++++++- 1 file changed, 835 insertions(+), 4 deletions(-) diff --git a/tests/unit/clients/apic-client.test.ts b/tests/unit/clients/apic-client.test.ts index 62492138..eb442a57 100644 --- a/tests/unit/clients/apic-client.test.ts +++ b/tests/unit/clients/apic-client.test.ts @@ -1,17 +1,34 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. /** - * Unit tests for ApicClient.validatePreFlight — resource group and API Center - * service existence checks, including principals without Reader on the - * resource group. + * Unit tests for ApicClient. + * + * - validatePreFlight: resource group and API Center service existence + * checks, including principals without Reader on the resource group. + * - ARM calls: URLs (with api-version and encoding), methods, headers, + * bodies, token caching, pagination, retry/back-off and long-running + * operation polling for every other ARM call the client makes. `fetch` is + * stubbed globally and `@azure/identity` is mocked so the tests never touch + * the network or a real credential; timers are faked so retry back-off and + * LRO polling run instantly. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { ApicClient, ApicHttpError } from '../../../src/clients/apic-client.js'; -import { ApicServiceContext } from '../../../src/models/apic-types.js'; +import { ApicResourceType } from '../../../src/models/apic-resource-types.js'; +import { ApicResourceDescriptor, ApicServiceContext } from '../../../src/models/apic-types.js'; import { buildApicBaseUrl } from '../../../src/lib/apic-uri.js'; +import { USER_AGENT } from '../../../src/lib/user-agent.js'; import { logger } from '../../../src/lib/logger.js'; +const mockGetToken = vi.hoisted(() => vi.fn()); + +vi.mock('@azure/identity', () => ({ + DefaultAzureCredential: class { + getToken = mockGetToken; + }, +})); + const testContext: ApicServiceContext = { subscriptionId: 'sub-1', resourceGroup: 'rg-1', @@ -159,3 +176,817 @@ describe('ApicClient.validatePreFlight', () => { expect(fetchSpy).toHaveBeenCalledTimes(1); }); }); + +/* ------------------------------------------------------------------ */ +/* ARM calls */ +/* ------------------------------------------------------------------ */ + +const BASE_URL = + 'https://management.azure.com/subscriptions/sub-1/resourceGroups/rg-1' + + '/providers/Microsoft.ApiCenter/services/apic-1'; +const API_VERSION = '2024-06-01-preview'; +const OPERATION_URL = + 'https://management.azure.com/subscriptions/sub-1/providers/Microsoft.ApiCenter' + + '/locations/eastus/operationStatuses/op-1?api-version=2024-06-01-preview'; + +const apiDescriptor: ApicResourceDescriptor = { + type: ApicResourceType.Api, + nameParts: ['pet store'], + workspace: 'default', +}; +const API_URL = `${BASE_URL}/workspaces/default/apis/pet%20store?api-version=${API_VERSION}`; + +const definitionDescriptor: ApicResourceDescriptor = { + type: ApicResourceType.ApiDefinition, + nameParts: ['petstore', 'v1', 'openapi'], + workspace: 'default', +}; +const DEFINITION_PATH = `${BASE_URL}/workspaces/default/apis/petstore/versions/v1/definitions/openapi`; +const DEFINITION_URL = `${DEFINITION_PATH}?api-version=${API_VERSION}`; + +function json(status: number, body: unknown, headers: Record = {}): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json', ...headers }, + }); +} + +function empty(status: number, headers: Record = {}): Response { + return new Response(status === 204 ? null : '', { status, headers }); +} + +/** + * Await a promise while running fake timers until it settles, so retry + * back-off and polling delays complete instantly. + */ +async function settle(promise: Promise): Promise { + let done = false; + const outcome = promise.then( + (value) => { + done = true; + return { ok: true as const, value }; + }, + (error: unknown) => { + done = true; + return { ok: false as const, error }; + }, + ); + for (let i = 0; i < 1000 && !done; i++) { + await vi.runAllTimersAsync(); + } + const result = await outcome; + if (!result.ok) throw result.error; + return result.value; +} + +async function collect(iterable: AsyncIterable): Promise { + const items: T[] = []; + for await (const item of iterable) { + items.push(item); + } + return items; +} + +describe('ApicClient ARM calls', () => { + let client: ApicClient; + let fetchSpy: ReturnType; + + /** URL, method, headers and body of the n-th (0-based) fetch call. */ + function call(n: number): { url: string; method: string; headers: Headers; body: unknown } { + const [url, init] = fetchSpy.mock.calls[n] as [string, RequestInit | undefined]; + return { + url, + method: init?.method ?? 'GET', + headers: new Headers(init?.headers), + body: init?.body, + }; + } + + function urls(): string[] { + return fetchSpy.mock.calls.map((c) => c[0] as string); + } + + beforeEach(() => { + vi.useFakeTimers(); + mockGetToken.mockReset(); + mockGetToken.mockImplementation(async () => ({ + token: 'fake-token', + expiresOnTimestamp: Date.now() + 60 * 60 * 1000, + })); + fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + vi.spyOn(logger, 'warn').mockImplementation(() => undefined); + vi.spyOn(logger, 'debug').mockImplementation(() => undefined); + client = new ApicClient(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + /* ------------------------------------------------------------------ */ + /* Authentication and common headers */ + /* ------------------------------------------------------------------ */ + + describe('authentication', () => { + it('requests a token for the public ARM scope by default', async () => { + fetchSpy.mockResolvedValueOnce(json(200, {})); + + await settle(client.getResource(testContext, apiDescriptor)); + + expect(mockGetToken).toHaveBeenCalledWith('https://management.azure.com/.default'); + }); + + it('requests a token for the scope passed to the constructor', async () => { + const scoped = new ApicClient('https://management.usgovcloudapi.net/.default'); + fetchSpy.mockResolvedValueOnce(json(200, {})); + + await settle(scoped.getResource(testContext, apiDescriptor)); + + expect(mockGetToken).toHaveBeenCalledWith('https://management.usgovcloudapi.net/.default'); + }); + + it('sends Authorization, Content-Type and User-Agent on ARM requests', async () => { + fetchSpy.mockResolvedValueOnce(json(200, {})); + + await settle(client.getResource(testContext, apiDescriptor)); + + const { headers } = call(0); + expect(headers.get('Authorization')).toBe('Bearer fake-token'); + expect(headers.get('Content-Type')).toBe('application/json'); + expect(headers.get('User-Agent')).toBe(USER_AGENT); + }); + + it('caches the token across requests', async () => { + fetchSpy.mockImplementation(async () => json(200, {})); + + await settle(client.getResource(testContext, apiDescriptor)); + await settle(client.getResource(testContext, apiDescriptor)); + await settle(client.putResource(testContext, apiDescriptor, {})); + + expect(mockGetToken).toHaveBeenCalledTimes(1); + expect(fetchSpy).toHaveBeenCalledTimes(3); + }); + + it('shares one in-flight token request between concurrent requests', async () => { + fetchSpy.mockImplementation(async () => json(200, {})); + + await settle( + Promise.all([ + client.getResource(testContext, apiDescriptor), + client.getResource(testContext, definitionDescriptor), + ]), + ); + + expect(mockGetToken).toHaveBeenCalledTimes(1); + }); + + it('refreshes the token once it is within five minutes of expiry', async () => { + const start = Date.now(); + mockGetToken + .mockResolvedValueOnce({ token: 'token-1', expiresOnTimestamp: start + 10 * 60 * 1000 }) + .mockResolvedValueOnce({ token: 'token-2', expiresOnTimestamp: start + 70 * 60 * 1000 }); + fetchSpy.mockImplementation(async () => json(200, {})); + + await settle(client.getResource(testContext, apiDescriptor)); + vi.setSystemTime(start + 4 * 60 * 1000); + await settle(client.getResource(testContext, apiDescriptor)); + vi.setSystemTime(start + 6 * 60 * 1000); + await settle(client.getResource(testContext, apiDescriptor)); + + expect(mockGetToken).toHaveBeenCalledTimes(2); + expect(call(0).headers.get('Authorization')).toBe('Bearer token-1'); + expect(call(1).headers.get('Authorization')).toBe('Bearer token-1'); + expect(call(2).headers.get('Authorization')).toBe('Bearer token-2'); + }); + }); + + /* ------------------------------------------------------------------ */ + /* listResources */ + /* ------------------------------------------------------------------ */ + + describe('listResources', () => { + it('lists a service-scoped collection', async () => { + fetchSpy.mockResolvedValueOnce(json(200, { value: [{ name: 'schema-a' }] })); + + const items = await settle( + collect(client.listResources(testContext, ApicResourceType.MetadataSchema, [])), + ); + + expect(items).toEqual([{ name: 'schema-a' }]); + expect(call(0).url).toBe(`${BASE_URL}/metadataSchemas?api-version=${API_VERSION}`); + expect(call(0).method).toBe('GET'); + expect(call(0).headers.get('Authorization')).toBe('Bearer fake-token'); + }); + + it('lists a workspace-scoped collection under the encoded workspace', async () => { + fetchSpy.mockResolvedValueOnce(json(200, { value: [] })); + + await settle( + collect(client.listResources(testContext, ApicResourceType.Environment, [], 'my ws')), + ); + + expect(call(0).url).toBe( + `${BASE_URL}/workspaces/my%20ws/environments?api-version=${API_VERSION}`, + ); + }); + + it('lists a child collection with encoded parent name-parts', async () => { + fetchSpy.mockResolvedValueOnce(json(200, { value: [{ name: 'openapi' }] })); + + await settle( + collect( + client.listResources( + testContext, + ApicResourceType.ApiDefinition, + ['pet store', 'v1'], + 'default', + ), + ), + ); + + expect(call(0).url).toBe( + `${BASE_URL}/workspaces/default/apis/pet%20store/versions/v1/definitions` + + `?api-version=${API_VERSION}`, + ); + }); + + it('follows nextLink until it is absent', async () => { + const page2 = `${BASE_URL}/workspaces/default/apis?api-version=${API_VERSION}&$skipToken=2`; + const page3 = `${BASE_URL}/workspaces/default/apis?api-version=${API_VERSION}&$skipToken=3`; + fetchSpy + .mockResolvedValueOnce(json(200, { value: [{ name: 'a' }], nextLink: page2 })) + .mockResolvedValueOnce(json(200, { value: [{ name: 'b' }], nextLink: page3 })) + .mockResolvedValueOnce(json(200, { value: [{ name: 'c' }] })); + + const items = await settle( + collect(client.listResources(testContext, ApicResourceType.Api, [], 'default')), + ); + + expect(items).toEqual([{ name: 'a' }, { name: 'b' }, { name: 'c' }]); + expect(urls()).toEqual([ + `${BASE_URL}/workspaces/default/apis?api-version=${API_VERSION}`, + page2, + page3, + ]); + expect(call(1).headers.get('Authorization')).toBe('Bearer fake-token'); + }); + + it('returns an empty list when the collection returns 404', async () => { + fetchSpy.mockResolvedValueOnce( + json(404, { error: { code: 'ResourceNotFound', message: 'missing' } }), + ); + + const items = await settle( + collect(client.listResources(testContext, ApicResourceType.Api, [], 'default')), + ); + + expect(items).toEqual([]); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('tolerates a page without a value array', async () => { + fetchSpy.mockResolvedValueOnce(json(200, {})); + + const items = await settle( + collect(client.listResources(testContext, ApicResourceType.Api, [], 'default')), + ); + + expect(items).toEqual([]); + }); + + it('throws ApicHttpError with status and ARM code on 403', async () => { + fetchSpy.mockResolvedValueOnce( + json(403, { error: { code: 'AuthorizationFailed', message: 'denied' } }), + ); + + const error = await settle( + collect(client.listResources(testContext, ApicResourceType.Api, [], 'default')), + ).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(ApicHttpError); + expect(error).toMatchObject({ status: 403, code: 'AuthorizationFailed' }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + }); + + /* ------------------------------------------------------------------ */ + /* getResource */ + /* ------------------------------------------------------------------ */ + + describe('getResource', () => { + it('GETs the encoded resource URL and returns the parsed body', async () => { + fetchSpy.mockResolvedValueOnce(json(200, { name: 'pet store', properties: { title: 'Pets' } })); + + const result = await settle(client.getResource(testContext, apiDescriptor)); + + expect(result).toEqual({ name: 'pet store', properties: { title: 'Pets' } }); + expect(call(0).url).toBe(API_URL); + expect(call(0).method).toBe('GET'); + expect(call(0).body).toBeUndefined(); + }); + + it('builds a service-scoped resource URL without a workspace segment', async () => { + fetchSpy.mockResolvedValueOnce(json(200, {})); + + await settle( + client.getResource(testContext, { type: ApicResourceType.MetadataSchema, nameParts: ['schema a'] }), + ); + + expect(call(0).url).toBe(`${BASE_URL}/metadataSchemas/schema%20a?api-version=${API_VERSION}`); + }); + + it('returns undefined on 404', async () => { + fetchSpy.mockResolvedValueOnce(json(404, { error: { code: 'ResourceNotFound' } })); + + await expect(settle(client.getResource(testContext, apiDescriptor))).resolves.toBeUndefined(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('returns an empty object for an empty 200 body', async () => { + fetchSpy.mockResolvedValueOnce(empty(200)); + + await expect(settle(client.getResource(testContext, apiDescriptor))).resolves.toEqual({}); + }); + }); + + /* ------------------------------------------------------------------ */ + /* putResource */ + /* ------------------------------------------------------------------ */ + + describe('putResource', () => { + const payload = { properties: { title: 'Pets', kind: 'rest' } }; + + it.each([200, 201])('PUTs the JSON payload and returns the %i body', async (status) => { + fetchSpy.mockResolvedValueOnce(json(status, { name: 'pet store', ...payload })); + + const result = await settle(client.putResource(testContext, apiDescriptor, payload)); + + expect(result).toEqual({ name: 'pet store', ...payload }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + const put = call(0); + expect(put.url).toBe(API_URL); + expect(put.method).toBe('PUT'); + expect(JSON.parse(put.body as string)).toEqual(payload); + expect(put.headers.get('Content-Type')).toBe('application/json'); + expect(put.headers.get('Authorization')).toBe('Bearer fake-token'); + expect(put.headers.get('User-Agent')).toBe(USER_AGENT); + }); + + it('returns an empty object for an empty 200 body', async () => { + fetchSpy.mockResolvedValueOnce(empty(200)); + + await expect(settle(client.putResource(testContext, apiDescriptor, payload))).resolves.toEqual({}); + }); + + it('polls Azure-AsyncOperation on 201 and returns the settled resource', async () => { + fetchSpy + .mockResolvedValueOnce(json(201, { name: 'pet store' }, { 'Azure-AsyncOperation': OPERATION_URL })) + .mockResolvedValueOnce(json(200, { status: 'InProgress' })) + .mockResolvedValueOnce(json(200, { status: 'Succeeded' })) + .mockResolvedValueOnce(json(200, { name: 'pet store', properties: { title: 'Settled' } })); + + const result = await settle(client.putResource(testContext, apiDescriptor, payload)); + + expect(result).toEqual({ name: 'pet store', properties: { title: 'Settled' } }); + expect(urls()).toEqual([API_URL, OPERATION_URL, OPERATION_URL, API_URL]); + expect(call(1).method).toBe('GET'); + expect(call(1).headers.get('Authorization')).toBe('Bearer fake-token'); + expect(call(3).method).toBe('GET'); + }); + + it('polls the Location header on 202 and reads provisioningState from the poll body', async () => { + const location = `${API_URL}&operation=1`; + fetchSpy + .mockResolvedValueOnce(empty(202, { Location: location })) + .mockResolvedValueOnce(empty(202)) + .mockResolvedValueOnce(json(200, { properties: { provisioningState: 'Succeeded' } })) + .mockResolvedValueOnce(json(200, { name: 'pet store' })); + + const result = await settle(client.putResource(testContext, apiDescriptor, payload)); + + expect(result).toEqual({ name: 'pet store' }); + expect(urls()).toEqual([API_URL, location, location, API_URL]); + }); + + it('throws ApicHttpError without retrying a 400', async () => { + fetchSpy.mockResolvedValueOnce( + json(400, { error: { code: 'ValidationError', message: 'bad title' } }), + ); + + const error = await settle(client.putResource(testContext, apiDescriptor, payload)).catch( + (e: unknown) => e, + ); + + expect(error).toBeInstanceOf(ApicHttpError); + expect(error).toMatchObject({ status: 400, code: 'ValidationError' }); + expect((error as Error).message).toContain('bad title'); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('throws ApicHttpError without a code for a non-JSON error body', async () => { + fetchSpy.mockResolvedValueOnce(new Response('Conflict', { status: 409 })); + + const error = await settle(client.putResource(testContext, apiDescriptor, payload)).catch( + (e: unknown) => e, + ); + + expect(error).toBeInstanceOf(ApicHttpError); + expect(error).toMatchObject({ status: 409, code: undefined }); + }); + }); + + /* ------------------------------------------------------------------ */ + /* deleteResource */ + /* ------------------------------------------------------------------ */ + + describe('deleteResource', () => { + it.each([200, 204])('DELETEs the resource URL and returns true on %i', async (status) => { + fetchSpy.mockResolvedValueOnce(empty(status)); + + await expect(settle(client.deleteResource(testContext, apiDescriptor))).resolves.toBe(true); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(call(0).url).toBe(API_URL); + expect(call(0).method).toBe('DELETE'); + expect(call(0).headers.get('Authorization')).toBe('Bearer fake-token'); + }); + + it('returns false on 404', async () => { + fetchSpy.mockResolvedValueOnce(json(404, { error: { code: 'ResourceNotFound' } })); + + await expect(settle(client.deleteResource(testContext, apiDescriptor))).resolves.toBe(false); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('polls Azure-AsyncOperation on 202 until Succeeded', async () => { + fetchSpy + .mockResolvedValueOnce(empty(202, { 'Azure-AsyncOperation': OPERATION_URL })) + .mockResolvedValueOnce(json(200, { status: 'InProgress' })) + .mockResolvedValueOnce(json(200, { status: 'Succeeded' })); + + await expect(settle(client.deleteResource(testContext, apiDescriptor))).resolves.toBe(true); + + expect(urls()).toEqual([API_URL, OPERATION_URL, OPERATION_URL]); + }); + + it('treats a 404 while polling a delete as success', async () => { + fetchSpy + .mockResolvedValueOnce(empty(202, { Location: OPERATION_URL })) + .mockResolvedValueOnce(empty(404)); + + await expect(settle(client.deleteResource(testContext, apiDescriptor))).resolves.toBe(true); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + }); + + /* ------------------------------------------------------------------ */ + /* exportSpecification */ + /* ------------------------------------------------------------------ */ + + describe('exportSpecification', () => { + const EXPORT_URL = `${DEFINITION_PATH}/exportSpecification?api-version=${API_VERSION}`; + const definition = { + name: 'openapi', + properties: { specification: { name: 'openapi', version: '3.0.1' } }, + }; + + it('POSTs the export action and returns inline content with the spec name/version', async () => { + fetchSpy + .mockResolvedValueOnce(json(200, { format: 'inline', value: 'openapi: 3.0.1' })) + .mockResolvedValueOnce(json(200, definition)); + + const spec = await settle(client.exportSpecification(testContext, definitionDescriptor)); + + expect(spec).toEqual({ content: 'openapi: 3.0.1', name: 'openapi', version: '3.0.1' }); + expect(urls()).toEqual([EXPORT_URL, DEFINITION_URL]); + const post = call(0); + expect(post.method).toBe('POST'); + expect(post.body).toBeUndefined(); + expect(post.headers.get('Authorization')).toBe('Bearer fake-token'); + expect(post.headers.get('Content-Type')).toBe('application/json'); + expect(post.headers.get('User-Agent')).toBe(USER_AGENT); + }); + + it('fetches a link export without ARM credentials', async () => { + const link = 'https://blob.example.net/specs/petstore.yaml?sv=2024&sig=secret'; + fetchSpy + .mockResolvedValueOnce(json(200, { format: 'link', value: link })) + .mockResolvedValueOnce(new Response('openapi: 3.0.1', { status: 200 })) + .mockResolvedValueOnce(json(200, definition)); + + const spec = await settle(client.exportSpecification(testContext, definitionDescriptor)); + + expect(spec).toEqual({ content: 'openapi: 3.0.1', name: 'openapi', version: '3.0.1' }); + expect(urls()).toEqual([EXPORT_URL, link, DEFINITION_URL]); + const blob = call(1); + expect(blob.method).toBe('GET'); + expect(blob.headers.get('Authorization')).toBeNull(); + expect(blob.headers.get('Content-Type')).toBeNull(); + expect(blob.headers.get('User-Agent')).toBe(USER_AGENT); + }); + + it('retries a link export that fails with a transient 5xx', async () => { + const link = 'https://blob.example.net/specs/petstore.yaml?sig=secret'; + fetchSpy + .mockResolvedValueOnce(json(200, { format: 'link', value: link })) + .mockResolvedValueOnce(new Response('busy', { status: 503 })) + .mockResolvedValueOnce(new Response('openapi: 3.0.1', { status: 200 })) + .mockResolvedValueOnce(json(200, definition)); + + const spec = await settle(client.exportSpecification(testContext, definitionDescriptor)); + + expect(spec?.content).toBe('openapi: 3.0.1'); + expect(urls()).toEqual([EXPORT_URL, link, link, DEFINITION_URL]); + }); + + it('returns undefined when the link export returns 404', async () => { + const link = 'https://blob.example.net/specs/missing.yaml?sig=secret'; + fetchSpy + .mockResolvedValueOnce(json(200, { format: 'link', value: link })) + .mockResolvedValueOnce(new Response('', { status: 404 })); + + await expect( + settle(client.exportSpecification(testContext, definitionDescriptor)), + ).resolves.toBeUndefined(); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it('defaults the spec name to openapi when the definition has none', async () => { + fetchSpy + .mockResolvedValueOnce(json(200, { format: 'inline', value: '{}' })) + .mockResolvedValueOnce(json(404, { error: { code: 'ResourceNotFound' } })); + + const spec = await settle(client.exportSpecification(testContext, definitionDescriptor)); + + expect(spec).toEqual({ content: '{}', name: 'openapi', version: undefined }); + }); + + it('returns undefined when the export action returns 404', async () => { + fetchSpy.mockResolvedValueOnce(json(404, { error: { code: 'ResourceNotFound' } })); + + await expect( + settle(client.exportSpecification(testContext, definitionDescriptor)), + ).resolves.toBeUndefined(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('warns and returns undefined when the export action fails', async () => { + fetchSpy.mockResolvedValueOnce(json(400, { error: { code: 'BadRequest' } })); + + await expect( + settle(client.exportSpecification(testContext, definitionDescriptor)), + ).resolves.toBeUndefined(); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('Failed to export specification for workspaces/default/apis/petstore'), + ); + }); + + it.each([ + ['an empty body', empty(200)], + ['no value', json(200, { format: 'inline' })], + ])('returns undefined for %s', async (_label, response) => { + fetchSpy.mockResolvedValueOnce(response); + + await expect( + settle(client.exportSpecification(testContext, definitionDescriptor)), + ).resolves.toBeUndefined(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + }); + + /* ------------------------------------------------------------------ */ + /* importSpecification */ + /* ------------------------------------------------------------------ */ + + describe('importSpecification', () => { + const IMPORT_URL = `${DEFINITION_PATH}/importSpecification?api-version=${API_VERSION}`; + + it('POSTs the inline spec with its name and version', async () => { + fetchSpy.mockResolvedValueOnce(empty(200)); + + await settle( + client.importSpecification(testContext, definitionDescriptor, { + content: 'openapi: 3.0.1', + name: 'openapi', + version: '3.0.1', + }), + ); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + const post = call(0); + expect(post.url).toBe(IMPORT_URL); + expect(post.method).toBe('POST'); + expect(JSON.parse(post.body as string)).toEqual({ + value: 'openapi: 3.0.1', + format: 'inline', + specification: { name: 'openapi', version: '3.0.1' }, + }); + expect(post.headers.get('Authorization')).toBe('Bearer fake-token'); + expect(post.headers.get('Content-Type')).toBe('application/json'); + }); + + it('omits the version when the spec has none', async () => { + fetchSpy.mockResolvedValueOnce(empty(200)); + + await settle( + client.importSpecification(testContext, definitionDescriptor, { content: 'x', name: 'graphql' }), + ); + + expect(JSON.parse(call(0).body as string).specification).toEqual({ name: 'graphql' }); + }); + + it('polls the operation when the import returns 202', async () => { + fetchSpy + .mockResolvedValueOnce(empty(202, { 'Azure-AsyncOperation': OPERATION_URL })) + .mockResolvedValueOnce(json(200, { status: 'Succeeded' })); + + await settle( + client.importSpecification(testContext, definitionDescriptor, { content: 'x', name: 'openapi' }), + ); + + expect(urls()).toEqual([IMPORT_URL, OPERATION_URL]); + }); + + it('surfaces a failed import operation', async () => { + fetchSpy + .mockResolvedValueOnce(empty(202, { 'Azure-AsyncOperation': OPERATION_URL })) + .mockResolvedValueOnce( + json(200, { status: 'Failed', error: { code: 'InvalidSpec', message: 'Spec is invalid' } }), + ); + + await expect( + settle( + client.importSpecification(testContext, definitionDescriptor, { content: 'x', name: 'openapi' }), + ), + ).rejects.toThrow(/workspaces\/default\/apis\/petstore\/versions\/v1\/definitions\/openapi.*spec import/); + }); + }); + + /* ------------------------------------------------------------------ */ + /* Retry and rate limiting */ + /* ------------------------------------------------------------------ */ + + describe('retries', () => { + it('retries a 5xx and then succeeds', async () => { + fetchSpy + .mockResolvedValueOnce(new Response('busy', { status: 503 })) + .mockResolvedValueOnce(new Response('oops', { status: 500 })) + .mockResolvedValueOnce(json(200, { name: 'pet store' })); + + await expect(settle(client.getResource(testContext, apiDescriptor))).resolves.toEqual({ + name: 'pet store', + }); + expect(urls()).toEqual([API_URL, API_URL, API_URL]); + }); + + it('gives up after three retries and throws the last 5xx as ApicHttpError', async () => { + fetchSpy.mockImplementation(async () => new Response('down', { status: 502 })); + + const error = await settle(client.getResource(testContext, apiDescriptor)).catch( + (e: unknown) => e, + ); + + expect(error).toBeInstanceOf(ApicHttpError); + expect(error).toMatchObject({ status: 502 }); + expect(fetchSpy).toHaveBeenCalledTimes(4); + }); + + it('retries a network error and then succeeds', async () => { + fetchSpy + .mockRejectedValueOnce(new TypeError('fetch failed')) + .mockResolvedValueOnce(json(200, { name: 'pet store' })); + + await expect(settle(client.getResource(testContext, apiDescriptor))).resolves.toEqual({ + name: 'pet store', + }); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it.each([400, 401, 403, 409])('does not retry a %i', async (status) => { + fetchSpy.mockResolvedValueOnce(json(status, { error: { code: 'Nope' } })); + + await expect( + settle(client.putResource(testContext, apiDescriptor, {})), + ).rejects.toMatchObject({ status, code: 'Nope' }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('waits for a numeric Retry-After after a 429 and then succeeds', async () => { + fetchSpy + .mockResolvedValueOnce(empty(429, { 'Retry-After': '2' })) + .mockResolvedValueOnce(json(200, { name: 'pet store' })); + + const result = client.getResource(testContext, apiDescriptor); + await vi.advanceTimersByTimeAsync(1999); + expect(fetchSpy).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + expect(fetchSpy).toHaveBeenCalledTimes(2); + + await expect(result).resolves.toEqual({ name: 'pet store' }); + }); + + it('backs off after a 429 without Retry-After and then succeeds', async () => { + fetchSpy + .mockResolvedValueOnce(empty(429)) + .mockResolvedValueOnce(json(200, { name: 'pet store' })); + + await expect(settle(client.getResource(testContext, apiDescriptor))).resolves.toEqual({ + name: 'pet store', + }); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + }); + + /* ------------------------------------------------------------------ */ + /* Long-running operation polling */ + /* ------------------------------------------------------------------ */ + + describe('long-running operation polling', () => { + function acceptedPut(): void { + fetchSpy.mockResolvedValueOnce(empty(202, { 'Azure-AsyncOperation': OPERATION_URL })); + } + + it('keeps polling while the operation is in progress', async () => { + acceptedPut(); + fetchSpy + .mockResolvedValueOnce(json(200, { status: 'Accepted' })) + .mockResolvedValueOnce(json(200, { status: 'InProgress' })) + .mockResolvedValueOnce(json(200, { status: 'Succeeded' })) + .mockResolvedValueOnce(json(200, { name: 'pet store' })); + + await expect( + settle(client.putResource(testContext, apiDescriptor, {})), + ).resolves.toEqual({ name: 'pet store' }); + expect(urls()).toEqual([API_URL, OPERATION_URL, OPERATION_URL, OPERATION_URL, API_URL]); + }); + + it('waits for a numeric Retry-After between polls', async () => { + acceptedPut(); + fetchSpy + .mockResolvedValueOnce(json(200, { status: 'InProgress' }, { 'Retry-After': '7' })) + .mockResolvedValueOnce(json(200, { status: 'Succeeded' })) + .mockResolvedValueOnce(json(200, { name: 'pet store' })); + + const result = client.putResource(testContext, apiDescriptor, {}); + await vi.advanceTimersByTimeAsync(0); + const pollsBefore = fetchSpy.mock.calls.length; + await vi.advanceTimersByTimeAsync(6999); + expect(fetchSpy).toHaveBeenCalledTimes(pollsBefore); + await vi.advanceTimersByTimeAsync(1); + await settle(result); + + expect(fetchSpy).toHaveBeenCalledTimes(4); + }); + + it.each(['Failed', 'Canceled'])('throws with the label and error message when %s', async (status) => { + acceptedPut(); + fetchSpy.mockResolvedValueOnce( + json(200, { status, error: { code: 'Boom', message: 'Spec is invalid' } }), + ); + + const error = (await settle(client.putResource(testContext, apiDescriptor, {})).catch( + (e: unknown) => e, + )) as Error; + + expect(error).toBeInstanceOf(Error); + expect(error.message).toMatch(new RegExp(status, 'i')); + expect(error.message).toContain('workspaces/default/apis/pet store'); + expect(error.message).toContain('Spec is invalid'); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it('throws when the operation disappears (404) while polling a PUT', async () => { + acceptedPut(); + fetchSpy.mockResolvedValueOnce(empty(404)); + + await expect(settle(client.putResource(testContext, apiDescriptor, {}))).rejects.toThrow( + /workspaces\/default\/apis\/pet store.*(not found|404)/, + ); + }); + + it('treats an empty 200 poll response as success', async () => { + acceptedPut(); + fetchSpy + .mockResolvedValueOnce(empty(200)) + .mockResolvedValueOnce(json(200, { name: 'pet store' })); + + await expect( + settle(client.putResource(testContext, apiDescriptor, {})), + ).resolves.toEqual({ name: 'pet store' }); + }); + + it('times out after ten minutes of polling', async () => { + acceptedPut(); + fetchSpy.mockImplementation(async () => json(200, { status: 'InProgress' })); + const start = Date.now(); + + await expect(settle(client.putResource(testContext, apiDescriptor, {}))).rejects.toThrow( + /timed out/, + ); + expect(Date.now() - start).toBeGreaterThanOrEqual(10 * 60 * 1000); + }); + }); +}); From e7a916380b78f109a6c53ff54c86e9359820ae57 Mon Sep 17 00:00:00 2001 From: Chris Dayne <1642847+cdayne@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:45:25 +1100 Subject: [PATCH 06/13] fix: harden API Center ARM calls ApicClient's ARM handling lagged behind the APIM client: - LRO URLs from Azure-AsyncOperation/Location were polled with the bearer token without checking the host, and Operation-Location was ignored. Polling URLs must now be HTTPS on a known ARM host (or a subdomain of one), otherwise they are ignored with a warning. Operation-Location is honoured after Azure-AsyncOperation. - nextLink was followed with the token without checking the host. A nextLink that is not HTTPS on a known ARM host now fails the list. - The final 429 slept again and then threw a plain "Max retries exceeded" error. It now throws ApicHttpError with status 429 and the ARM code. - Retry-After was read with parseInt, so an HTTP-date gave delay(NaN) and an immediate retry or poll. It is now parsed as delay-seconds or an HTTP-date; anything else falls back to the back-off (requests) or the default polling interval (LRO). - LRO status was compared case-sensitively and "Cancelled" was not terminal. Status is now compared case-insensitively. - A PUT that returns 200 with Azure-AsyncOperation/Operation-Location was not polled. It now is; a Location header on a 200 is still not polled. - A DELETE that returns 202 without a (valid) polling header returned true immediately. It now polls the resource until it is gone (404) or reports a terminal provisioningState. - skipAuth now also strips Proxy-Authorization and x-ms-authorization-auxiliary, as the APIM client does. The allowed ARM hosts come from a new getArmHosts() in cloud-config instead of another hard-coded list. --- src/clients/apic-client.ts | 132 +++++++++---- src/lib/cloud-config.ts | 9 + tests/unit/clients/apic-client.test.ts | 249 +++++++++++++++++++++++++ tests/unit/lib/cloud-config.test.ts | 12 ++ 4 files changed, 365 insertions(+), 37 deletions(-) diff --git a/src/clients/apic-client.ts b/src/clients/apic-client.ts index d51e9138..d7beb764 100644 --- a/src/clients/apic-client.ts +++ b/src/clients/apic-client.ts @@ -20,6 +20,7 @@ import { } from '../lib/apic-uri.js'; import { logger } from '../lib/logger.js'; import { USER_AGENT } from '../lib/user-agent.js'; +import { getArmHosts } from '../lib/cloud-config.js'; import { validateServicePreFlight, PreFlightServiceKind } from './preflight.js'; /** HTTP error carrying the response status code and optional ARM error code. */ @@ -34,9 +35,43 @@ export class ApicHttpError extends Error { } } -/** Terminal states for an ARM long-running operation. */ -const TERMINAL_SUCCESS = 'Succeeded'; -const TERMINAL_FAILURES = new Set(['Failed', 'Canceled']); +/** Terminal states for an ARM long-running operation (compared lower-case). */ +const TERMINAL_SUCCESS = 'succeeded'; +const TERMINAL_FAILURES = new Set(['failed', 'canceled', 'cancelled']); + +/** + * Parse a `Retry-After` header (delay-seconds or HTTP-date) into milliseconds. + * Returns undefined when the header is absent or unparsable. + */ +function parseRetryAfterMs(value: string | null): number | undefined { + if (value === null) { + return undefined; + } + const trimmed = value.trim(); + if (/^\d+$/.test(trimmed)) { + return Number(trimmed) * 1000; + } + const date = Date.parse(trimmed); + return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now()); +} + +/** + * True when `url` is an HTTPS URL on a known ARM host (or a subdomain of one), + * i.e. safe to send the ARM bearer token to. + */ +function isArmUrl(url: string): boolean { + try { + const parsed = new URL(url); + return ( + parsed.protocol === 'https:' && + getArmHosts().some( + (host) => parsed.hostname === host || parsed.hostname.endsWith(`.${host}`), + ) + ); + } catch { + return false; + } +} /** Pre-flight wording for API Center services. */ const APIC_PREFLIGHT_KIND: PreFlightServiceKind = { @@ -113,6 +148,8 @@ export class ApicClient implements IApicClient { const headers = new Headers(options.headers); if (skipAuth) { headers.delete('Authorization'); + headers.delete('Proxy-Authorization'); + headers.delete('x-ms-authorization-auxiliary'); } else { headers.set('Authorization', `Bearer ${await this.getToken()}`); headers.set('Content-Type', 'application/json'); @@ -129,11 +166,12 @@ export class ApicClient implements IApicClient { logger.debug(`HTTP ${method} ${logUrl}`); const response = await fetch(url, { ...options, headers }); - if (response.status === 429) { - const retryAfter = response.headers.get('Retry-After'); - const delaySeconds = retryAfter ? parseInt(retryAfter, 10) : Math.pow(2, attempt); - logger.warn(`Rate limited (429), retrying after ${delaySeconds}s`); - await this.delay(delaySeconds * 1000); + // On the final attempt a 429 falls through and is thrown with its status. + if (response.status === 429 && attempt < ApicClient.MAX_RETRIES) { + const delayMs = + parseRetryAfterMs(response.headers.get('Retry-After')) ?? Math.pow(2, attempt) * 1000; + logger.warn(`Rate limited (429), retrying after ${delayMs / 1000}s`); + await this.delay(delayMs); attempt++; continue; } @@ -219,6 +257,12 @@ export class ApicClient implements IApicClient { } } url = data.nextLink ?? ''; + // nextLink comes from the response; never send the token anywhere but ARM. + if (url && !isArmUrl(url)) { + throw new Error( + `Refusing to follow nextLink to a non-ARM URL: ${url.split('?')[0]}`, + ); + } } } @@ -246,13 +290,13 @@ export class ApicClient implements IApicClient { body: JSON.stringify(payload), }); - if (response.status === 201 || response.status === 202) { - const asyncUrl = this.asyncOperationUrl(response); - if (asyncUrl) { - await this.pollAsyncOperation(asyncUrl, buildApicLabel(descriptor)); - const settled = await this.getResource(context, descriptor); - return settled ?? {}; - } + // Poll any async-operation header, including updates that return 200 while + // provisioning continues. A Location header on a 200 is not a polling URL. + const asyncUrl = this.asyncOperationUrl(response, response.status !== 200); + if (asyncUrl) { + await this.pollAsyncOperation(asyncUrl, buildApicLabel(descriptor)); + const settled = await this.getResource(context, descriptor); + return settled ?? {}; } const text = await response.text(); @@ -269,10 +313,10 @@ export class ApicClient implements IApicClient { return false; } if (response.status === 202) { - const asyncUrl = this.asyncOperationUrl(response); - if (asyncUrl) { - await this.pollAsyncOperation(asyncUrl, buildApicLabel(descriptor), true); - } + // Without a (valid) polling header, poll the resource itself until it + // is gone: 404 is success and a Failed provisioningState fails. + const asyncUrl = this.asyncOperationUrl(response) ?? url; + await this.pollAsyncOperation(asyncUrl, buildApicLabel(descriptor), true); } return true; } @@ -382,13 +426,25 @@ export class ApicClient implements IApicClient { return `${base}/${action}?${query}`; } - /** Extract the LRO polling URL from response headers, if any. */ - private asyncOperationUrl(response: Response): string | undefined { - return ( + /** + * Extract the LRO polling URL from response headers, if any. Prefers + * Azure-AsyncOperation, then Operation-Location, then (when + * `includeLocation`) Location. URLs that are not HTTPS on a known ARM host + * are ignored so the bearer token is never sent elsewhere. + */ + private asyncOperationUrl(response: Response, includeLocation = true): string | undefined { + const asyncUrl = response.headers.get('Azure-AsyncOperation') ?? - response.headers.get('Location') ?? - undefined - ); + response.headers.get('Operation-Location') ?? + (includeLocation ? response.headers.get('Location') : null); + if (!asyncUrl) { + return undefined; + } + if (!isArmUrl(asyncUrl)) { + logger.warn(`Ignoring async operation URL that is not HTTPS on a known ARM host: ${asyncUrl.split('?')[0]}`); + return undefined; + } + return asyncUrl; } /** @@ -411,21 +467,22 @@ export class ApicClient implements IApicClient { throw new Error(`Async operation for ${label} not found (404).`); } - const retryAfter = response.headers.get('Retry-After'); + const retryAfterMs = parseRetryAfterMs(response.headers.get('Retry-After')); const text = await response.text(); const status = this.readOperationStatus(text, response.status); - if (status === TERMINAL_SUCCESS) { + if (status.toLowerCase() === TERMINAL_SUCCESS) { return; } - if (TERMINAL_FAILURES.has(status)) { + if (TERMINAL_FAILURES.has(status.toLowerCase())) { throw new Error(`Async operation for ${label} ${status}: ${text.substring(0, 500)}`); } - const waitMs = retryAfter - ? parseInt(retryAfter, 10) * 1000 - : ApicClient.ASYNC_POLL_INTERVAL_MS; - await this.delay(waitMs); + await this.delay( + retryAfterMs !== undefined && retryAfterMs > 0 + ? retryAfterMs + : ApicClient.ASYNC_POLL_INTERVAL_MS, + ); } throw new Error(`Async operation for ${label} timed out after ${ApicClient.ASYNC_POLL_TIMEOUT_MS}ms.`); } @@ -437,18 +494,19 @@ export class ApicClient implements IApicClient { * treated as success. */ private readOperationStatus(text: string, httpStatus: number): string { + const fallback = httpStatus === 200 ? 'Succeeded' : 'InProgress'; if (!text.trim()) { - return httpStatus === 200 ? TERMINAL_SUCCESS : 'InProgress'; + return fallback; } try { const body = JSON.parse(text) as { - status?: string; - properties?: { provisioningState?: string }; + status?: unknown; + properties?: { provisioningState?: unknown }; }; const state = body.status ?? body.properties?.provisioningState; - return state ?? (httpStatus === 200 ? TERMINAL_SUCCESS : 'InProgress'); + return typeof state === 'string' ? state : fallback; } catch { - return httpStatus === 200 ? TERMINAL_SUCCESS : 'InProgress'; + return fallback; } } } diff --git a/src/lib/cloud-config.ts b/src/lib/cloud-config.ts index 40eb437b..a84968d2 100644 --- a/src/lib/cloud-config.ts +++ b/src/lib/cloud-config.ts @@ -77,6 +77,15 @@ export function getCloudConfig(cloudName: string): CloudConfig { throw new Error(`Unknown cloud "${cloudName}". Valid values: ${validNames}`); } +/** + * Host names of the ARM endpoints of all known clouds + * (e.g. `management.azure.com`). Used to check that a URL returned by ARM + * (async-operation, `nextLink`) points at ARM before a bearer token is sent to it. + */ +export function getArmHosts(): string[] { + return Object.values(CLOUD_CONFIGS).map((config) => new URL(config.armBaseUrl).hostname); +} + /** * Build the APIM ARM base URL for a given cloud, subscription, resource group, * and service name. Replaces the hardcoded buildBaseUrl functions in command files. diff --git a/tests/unit/clients/apic-client.test.ts b/tests/unit/clients/apic-client.test.ts index eb442a57..d0ae1e02 100644 --- a/tests/unit/clients/apic-client.test.ts +++ b/tests/unit/clients/apic-client.test.ts @@ -988,5 +988,254 @@ describe('ApicClient ARM calls', () => { ); expect(Date.now() - start).toBeGreaterThanOrEqual(10 * 60 * 1000); }); + + it.each([ + ['succeeded', true], + ['SUCCEEDED', true], + ['failed', false], + ['canceled', false], + ['Cancelled', false], + ])('matches the terminal status %s case-insensitively', async (status, succeeds) => { + acceptedPut(); + fetchSpy + .mockResolvedValueOnce(json(200, { status, error: { message: 'Spec is invalid' } })) + .mockResolvedValueOnce(json(200, { name: 'pet store' })); + + const outcome = settle(client.putResource(testContext, apiDescriptor, {})); + + if (succeeds) { + await expect(outcome).resolves.toEqual({ name: 'pet store' }); + expect(fetchSpy).toHaveBeenCalledTimes(3); + } else { + await expect(outcome).rejects.toThrow(new RegExp(`${status}.*Spec is invalid`, 'i')); + expect(fetchSpy).toHaveBeenCalledTimes(2); + } + }); + + it('waits until an HTTP-date Retry-After between polls', async () => { + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + acceptedPut(); + fetchSpy + .mockResolvedValueOnce( + json(200, { status: 'InProgress' }, { 'Retry-After': 'Thu, 01 Jan 2026 00:00:08 GMT' }), + ) + .mockResolvedValueOnce(json(200, { status: 'Succeeded' })) + .mockResolvedValueOnce(json(200, { name: 'pet store' })); + + const result = client.putResource(testContext, apiDescriptor, {}); + await vi.advanceTimersByTimeAsync(7999); + expect(fetchSpy).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(1); + await settle(result); + + expect(fetchSpy).toHaveBeenCalledTimes(4); + }); + + it('falls back to the default interval for an unparsable Retry-After', async () => { + acceptedPut(); + fetchSpy + .mockResolvedValueOnce(json(200, { status: 'InProgress' }, { 'Retry-After': 'soon' })) + .mockResolvedValueOnce(json(200, { status: 'Succeeded' })) + .mockResolvedValueOnce(json(200, { name: 'pet store' })); + + const result = client.putResource(testContext, apiDescriptor, {}); + await vi.advanceTimersByTimeAsync(4999); + expect(fetchSpy).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(1); + await settle(result); + + expect(fetchSpy).toHaveBeenCalledTimes(4); + }); + + it('polls Azure-AsyncOperation when a PUT update returns 200', async () => { + fetchSpy + .mockResolvedValueOnce( + json(200, { properties: { provisioningState: 'Updating' } }, { 'Azure-AsyncOperation': OPERATION_URL }), + ) + .mockResolvedValueOnce(json(200, { status: 'Succeeded' })) + .mockResolvedValueOnce(json(200, { name: 'pet store', properties: { title: 'Settled' } })); + + const result = await settle(client.putResource(testContext, apiDescriptor, {})); + + expect(result).toEqual({ name: 'pet store', properties: { title: 'Settled' } }); + expect(urls()).toEqual([API_URL, OPERATION_URL, API_URL]); + }); + + it('does not poll a Location header on a PUT that returns 200', async () => { + fetchSpy.mockResolvedValueOnce(json(200, { name: 'pet store' }, { Location: OPERATION_URL })); + + await expect( + settle(client.putResource(testContext, apiDescriptor, {})), + ).resolves.toEqual({ name: 'pet store' }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('polls the resource when a DELETE returns 202 without a polling header', async () => { + fetchSpy + .mockResolvedValueOnce(empty(202)) + .mockResolvedValueOnce(json(200, { name: 'pet store', properties: { provisioningState: 'Deleting' } })) + .mockResolvedValueOnce(empty(404)); + + await expect(settle(client.deleteResource(testContext, apiDescriptor))).resolves.toBe(true); + + expect(urls()).toEqual([API_URL, API_URL, API_URL]); + expect(call(0).method).toBe('DELETE'); + expect(call(1).method).toBe('GET'); + expect(call(2).method).toBe('GET'); + }); + + it('fails a DELETE without a polling header when the resource reports Failed', async () => { + fetchSpy + .mockResolvedValueOnce(empty(202)) + .mockResolvedValueOnce(json(200, { properties: { provisioningState: 'Failed' } })); + + await expect(settle(client.deleteResource(testContext, apiDescriptor))).rejects.toThrow( + /workspaces\/default\/apis\/pet store.*failed/i, + ); + }); + }); + + /* ------------------------------------------------------------------ */ + /* Rate limiting hardening */ + /* ------------------------------------------------------------------ */ + + describe('rate limiting', () => { + it('throws ApicHttpError 429 once the retries are exhausted', async () => { + fetchSpy.mockImplementation(async () => + json(429, { error: { code: 'TooManyRequests', message: 'slow down' } }, { 'Retry-After': '1' }), + ); + + const error = await settle(client.getResource(testContext, apiDescriptor)).catch( + (e: unknown) => e, + ); + + expect(error).toBeInstanceOf(ApicHttpError); + expect(error).toMatchObject({ status: 429, code: 'TooManyRequests' }); + expect(fetchSpy).toHaveBeenCalledTimes(4); + }); + + it('does not wait again after the final 429', async () => { + fetchSpy.mockImplementation(async () => empty(429, { 'Retry-After': '10' })); + + const result = client.getResource(testContext, apiDescriptor).catch((e: unknown) => e); + // Three retries wait 10s each; the fourth 429 must fail without a fourth wait. + await vi.advanceTimersByTimeAsync(30_000); + + expect(fetchSpy).toHaveBeenCalledTimes(4); + await expect(result).resolves.toMatchObject({ status: 429 }); + }); + + it('waits until an HTTP-date Retry-After after a 429', async () => { + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + fetchSpy + .mockResolvedValueOnce(empty(429, { 'Retry-After': 'Thu, 01 Jan 2026 00:00:03 GMT' })) + .mockResolvedValueOnce(json(200, { name: 'pet store' })); + + const result = client.getResource(testContext, apiDescriptor); + await vi.advanceTimersByTimeAsync(2999); + expect(fetchSpy).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + expect(fetchSpy).toHaveBeenCalledTimes(2); + + await expect(result).resolves.toEqual({ name: 'pet store' }); + }); + + it('backs off exponentially for an unparsable Retry-After after a 429', async () => { + fetchSpy + .mockResolvedValueOnce(empty(429, { 'Retry-After': 'later' })) + .mockResolvedValueOnce(json(200, { name: 'pet store' })); + + const result = client.getResource(testContext, apiDescriptor); + await vi.advanceTimersByTimeAsync(999); + expect(fetchSpy).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + expect(fetchSpy).toHaveBeenCalledTimes(2); + + await expect(result).resolves.toEqual({ name: 'pet store' }); + }); + }); + + /* ------------------------------------------------------------------ */ + /* ARM host validation */ + /* ------------------------------------------------------------------ */ + + describe('ARM host validation', () => { + it.each([ + ['a non-HTTPS URL', 'http://management.azure.com/operations/op-1'], + ['an unknown host', 'https://attacker.example.com/operations/op-1'], + ['a look-alike host', 'https://management.azure.com.attacker.example.com/operations/op-1'], + ])('does not poll (or send the token to) %s from a PUT', async (_label, operationUrl) => { + fetchSpy.mockResolvedValueOnce( + json(201, { name: 'pet store' }, { 'Azure-AsyncOperation': operationUrl }), + ); + + await expect( + settle(client.putResource(testContext, apiDescriptor, {})), + ).resolves.toEqual({ name: 'pet store' }); + expect(urls()).toEqual([API_URL]); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Ignoring')); + }); + + it('does not poll an unknown host from a DELETE and polls the resource instead', async () => { + fetchSpy + .mockResolvedValueOnce(empty(202, { Location: 'https://attacker.example.com/op' })) + .mockResolvedValueOnce(empty(404)); + + await expect(settle(client.deleteResource(testContext, apiDescriptor))).resolves.toBe(true); + expect(urls()).toEqual([API_URL, API_URL]); + }); + + it.each([ + 'https://management.usgovcloudapi.net/operations/op-1', + 'https://eastus.management.azure.com/operations/op-1', + ])('polls an operation on the known ARM host %s', async (operationUrl) => { + fetchSpy + .mockResolvedValueOnce(empty(202, { 'Azure-AsyncOperation': operationUrl })) + .mockResolvedValueOnce(json(200, { status: 'Succeeded' })); + + await expect(settle(client.deleteResource(testContext, apiDescriptor))).resolves.toBe(true); + expect(urls()).toEqual([API_URL, operationUrl]); + }); + + it('polls Operation-Location', async () => { + fetchSpy + .mockResolvedValueOnce(empty(202, { 'Operation-Location': OPERATION_URL })) + .mockResolvedValueOnce(json(200, { status: 'Succeeded' })); + + await expect(settle(client.deleteResource(testContext, apiDescriptor))).resolves.toBe(true); + expect(urls()).toEqual([API_URL, OPERATION_URL]); + }); + + it('prefers Azure-AsyncOperation over Location', async () => { + fetchSpy + .mockResolvedValueOnce( + empty(202, { 'Azure-AsyncOperation': OPERATION_URL, Location: `${API_URL}&location=1` }), + ) + .mockResolvedValueOnce(json(200, { status: 'Succeeded' })); + + await expect(settle(client.deleteResource(testContext, apiDescriptor))).resolves.toBe(true); + expect(urls()).toEqual([API_URL, OPERATION_URL]); + }); + + it.each([ + ['an unknown host', 'https://attacker.example.com/apis?page=2'], + ['a non-HTTPS URL', 'http://management.azure.com/apis?page=2'], + ])('refuses to follow a nextLink to %s', async (_label, nextLink) => { + fetchSpy.mockResolvedValueOnce(json(200, { value: [{ name: 'a' }], nextLink })); + + const items: unknown[] = []; + const error = await settle( + (async () => { + for await (const item of client.listResources(testContext, ApicResourceType.Api, [], 'default')) { + items.push(item); + } + })(), + ).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toMatch(/nextLink/); + expect(items).toEqual([{ name: 'a' }]); + expect(urls()).toEqual([`${BASE_URL}/workspaces/default/apis?api-version=${API_VERSION}`]); + }); }); }); diff --git a/tests/unit/lib/cloud-config.test.ts b/tests/unit/lib/cloud-config.test.ts index b6b0b49d..731624da 100644 --- a/tests/unit/lib/cloud-config.test.ts +++ b/tests/unit/lib/cloud-config.test.ts @@ -8,6 +8,7 @@ import { describe, it, expect } from 'vitest'; import { getCloudConfig, buildArmBaseUrl, + getArmHosts, } from '../../../src/lib/cloud-config.js'; describe('getCloudConfig', () => { @@ -147,3 +148,14 @@ describe('buildArmBaseUrl', () => { ); }); }); + +describe('getArmHosts', () => { + it('should return the ARM host name of every known cloud', () => { + expect(getArmHosts()).toEqual([ + 'management.azure.com', + 'management.chinacloudapi.cn', + 'management.usgovcloudapi.net', + 'management.microsoftazure.de', + ]); + }); +}); From d2e4e90bd369fe033a51feb5b8ca3ada5ebb24e6 Mon Sep 17 00:00:00 2001 From: Chris Dayne <1642847+cdayne@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:15:39 +1100 Subject: [PATCH 07/13] fix: share ARM HTTP handling between the APIM and API Center clients ApimClient, ApicClient and the Key Vault pre-flight check each had their own token cache, retry loop, error class, pagination and long-running operation (LRO) polling. Move that into src/clients/arm-http-client.ts (HttpError, ArmHttpClient, paginateArm, LRO polling, waitUntilGone) and have all three use it. Each client keeps only its service-specific rules (APIM's 409 PessimisticConcurrencyConflict retry, wiki 500 suppression, DELETE 412 retries). Retry and polling intervals and deadlines are main's values. Requests: - Tokens come from @azure/identity's getBearerTokenProvider (the Azure SDK's token cycler) instead of a hand-written cache. The shared client needs one token cache, and the SDK's already handles refreshing and concurrent callers, so none is kept here: concurrent callers share one refresh; from 2 minutes before expiry (or the credential's refreshAfterTimestamp) the token is refreshed in the background while the current one is used, instead of main's blocking refresh 5 minutes before expiry. - Polling and nextLink URLs are followed only over HTTPS on the client's own cloud's ARM host, so one cloud's token is never sent to another cloud's host (main's APIM client accepted the ARM hosts of all four clouds); a rejected polling header falls back to the next. - Throttling waits follow the Azure SDK's throttlingRetryStrategy: on a 429 or 503, retry-after-ms, then x-ms-retry-after-ms, then Retry-After (seconds or an HTTP date) is waited for as sent; 0 or a past date retries at once. A 429 without a readable header waits 1, 2 and then 4 seconds as main does; a 503 without one backs off. The final 429 keeps its status. - Error messages carry the ARM code, message and the messages of its details, nested ones included (APIM explains a ValidationError there), in full, instead of the full response body, which stays on error.body. A body with no ARM error is cut to its first 500 characters, the length main's API Center client cut operation failures to. A failed long-running operation's message has its details too. - A failed download from a SAS link (an export's result) names the URL without its query and keeps the body, with the SAS signature masked in the message and in error.body. The body is text the CLI doesn't control: a proxy's or firewall's page can quote or link to the URL. (The Storage authentication errors seen in the integration run don't quote it.) Any sig parameter is masked, also in a percent-encoded URL or after a JSON-escaped separator (\u0026), its value running only over the characters a signature can have as written (base64, %XX escapes, a JSON '\', URL-safe '-' and '_'). So is the request's own signature wherever else it appears, when it is one (the base64 of a 32-byte HMAC-SHA256), including JSON-escaped with \/ or \u002B. The signature is read as sent, decoding only %XX escapes, so an unencoded '+' stays a '+'. The rest of the body still shows. main put the whole body, signature included, in the message. - The error class is HttpError, the name the APIM client's had on main, for ARM calls and the SAS links export actions return alike. ApicHttpError remains as a deprecated alias, so API Center errors now print as HttpError instead of ApicHttpError. - API Center's retry messages use the shared format and go through logRetry, so inside a retry-tracking scope they are logged at debug, as APIM's are. - Response bodies that are not read (retried responses, ignored 404s, PUT and DELETE responses followed by polling, pre-flight responses) are released so their connections are freed. Long-running operations: - Between polls a positive Retry-After is kept until another replaces it (a missing, unreadable, 0 or past value keeps the current wait), as main's APIM client and @azure/core-lro do; API Center used to go back to its 5-second interval. - A 404 while polling ends the poll (success for deletes) instead of running to the timeout; states are matched case-insensitively. - After a PUT, APIM's provisioning-state poll matches states ignoring case and fails at once on Canceled or Cancelled, as on Failed: a cancelled operation never succeeds, and main kept polling for up to 60 seconds and then reported a timeout. - Azure-AsyncOperation and Operation-Location status bodies without a status keep polling; a Location poll reads properties.provisioningState and fails on an operation-status body reporting Failed, Canceled or Cancelled; JSON null or non-object bodies no longer throw. - A Location poll that answers 2xx with a JSON body and no state is done: under ARM's asynchronous pattern a Location URL answers 202 while the operation runs and its result once it has finished. main's APIM client kept polling such a body until its 7.5-minute timeout. - A DELETE accepted without a polling URL waits until the resource is gone, whatever state it reports. APIM checks every 2 seconds for up to 60 seconds, main's limit; main stopped as soon as the resource reported no provisioningState or Succeeded, so it usually reported success at once. API Center checks every 5 seconds for up to 10 minutes, main's operation deadline; the previous commit also stopped on a terminal provisioningState, and it now checks presence only, without parsing the body. Key Vault check: uses the shared client (retries, one client per auth scope); creates the client inside its soft-skip; logs "Key Vault access confirmed" only when access was confirmed. Its access decisions are the same as on main. Docs list the ARM reads it makes, the permission each needs, when it fails a named value and its known limitation (grants through a group are not seen). The publish pre-flight helper uses HttpError and gets its own tests. The clients accept an injected credential and wait function for tests. Docs describe how 429 retries wait. Client tests that only repeated the shared client's token, retry, Retry-After and polling behaviour are removed, since arm-http-client.test.ts covers it; each client keeps the tests for its own choices (URLs, statuses, intervals, deadlines, hosts, body release). The clients' pre-flight tests are consolidated into preflight.test.ts, keeping three per client for its own wording: a possibly wrong resource group, the permission error, and the missing service. Non-OK checks that could never run (after requests that throw on those statuses) are removed, and paginateArm's requestOptions can no longer allow an error response to be read as a page. --- docs/ci-cd/authentication-patterns.md | 23 + docs/guides/environment-overrides.md | 2 + docs/troubleshooting/common-errors.md | 2 +- src/clients/apic-client.ts | 414 ++---- src/clients/apim-client.ts | 602 ++------ src/clients/arm-http-client.ts | 970 +++++++++++++ src/clients/iarm-http-client.ts | 20 + src/clients/preflight.ts | 14 +- src/lib/cloud-config.ts | 13 + src/lib/error-message.ts | 10 + src/services/keyvault-checker.ts | 157 ++- tests/unit/clients/apic-client.test.ts | 620 +++------ tests/unit/clients/apim-client.test.ts | 480 ++++--- tests/unit/clients/arm-http-client.test.ts | 1294 ++++++++++++++++++ tests/unit/clients/preflight.test.ts | 211 +++ tests/unit/lib/cloud-config.test.ts | 14 + tests/unit/lib/error-message.test.ts | 21 + tests/unit/services/keyvault-checker.test.ts | 438 +++++- tests/unit/services/publish-service.test.ts | 58 +- 19 files changed, 3852 insertions(+), 1511 deletions(-) create mode 100644 src/clients/arm-http-client.ts create mode 100644 src/clients/iarm-http-client.ts create mode 100644 src/lib/error-message.ts create mode 100644 tests/unit/clients/arm-http-client.test.ts create mode 100644 tests/unit/clients/preflight.test.ts create mode 100644 tests/unit/lib/error-message.test.ts diff --git a/docs/ci-cd/authentication-patterns.md b/docs/ci-cd/authentication-patterns.md index 726d35e5..d951e600 100644 --- a/docs/ci-cd/authentication-patterns.md +++ b/docs/ci-cd/authentication-patterns.md @@ -308,6 +308,29 @@ apiops-cli needs these Azure RBAC roles on the APIM service instance: **Reader** on the resource group is not required. Before publishing, `apiops publish` checks that the resource group exists so it can report a missing resource group clearly. If the identity cannot read the resource group (HTTP 403), the CLI skips that check and checks the APIM service directly. +### Key Vault pre-flight check + +When a named value references a Key Vault secret (`keyVault.secretIdentifier`), `apiops publish` first checks that the APIM managed identity can read that secret. It uses the CLI's identity to make these ARM reads: + +| Read | Why | Permission | Covered by | +|------|-----|------------|------------| +| The APIM service | Find its managed identity | `Microsoft.ApiManagement/service/read` | API Management Service Contributor on the APIM service | +| Resources in the APIM service's subscription, filtered to Key Vaults with the vault's name | Find the vault | Read on the vault; the [Resources - List](https://learn.microsoft.com/rest/api/resources/resources/list) API documents no specific permission, or what it returns when access is narrower than the subscription | Reader on the subscription | +| The Key Vault | See whether it uses RBAC or access policies | `Microsoft.KeyVault/vaults/read` | Reader on the vault | +| RBAC vaults: role assignments for the APIM identity on the vault | Find its roles | `Microsoft.Authorization/roleAssignments/read` on the vault | Reader on the vault | +| RBAC vaults with custom roles: the role definitions | Check their data actions | `Microsoft.Authorization/roleDefinitions/read` on the role definition's scope, usually the subscription | Reader on the subscription | + +Reader on the subscription that contains the APIM service and the vault covers all of these reads. With narrower access, a failed read (for example HTTP 403) of the APIM service, the vault search, the vault or its role assignments, or a vault that isn't found, makes the CLI log a warning and skip the check; the named value is still published. The reads go to the ARM endpoint of the cloud selected with `--cloud`. + +The check fails the named value when: + +- the secret identifier is not a valid Key Vault secret URI; +- the APIM service has no managed identity (or not the user-assigned identity named by `keyVault.identityClientId`); +- on an RBAC vault, no role assignment it can see grants the identity secret read access: the identity has no role assignments on the vault, or none of its roles includes the `Microsoft.KeyVault/vaults/secrets/getSecret/action` data action (or a wildcard that covers it). A custom role definition that can't be read is skipped, so when no other role grants the data action, the check fails; +- on a vault that uses access policies, no access policy it can see grants the identity `get` (or `all`) on secrets, including when the identity has no access policy. + +Known limitation: the check sees only role assignments and access policies made to the APIM identity itself, not those made to a group it belongs to (an access policy can name a group, see [Assign a Key Vault access policy](https://learn.microsoft.com/azure/key-vault/general/assign-access-policy)). An identity whose access comes only through a group fails the check. + ### Assign RBAC ```bash diff --git a/docs/guides/environment-overrides.md b/docs/guides/environment-overrides.md index b6be22fa..e861c3a6 100644 --- a/docs/guides/environment-overrides.md +++ b/docs/guides/environment-overrides.md @@ -551,6 +551,8 @@ If you add a new backend in dev but forget to add it to your override files, pub When using Key Vault references, the APIM managed identity needs access to the Key Vault. A common failure mode: overrides reference a Key Vault but APIM lacks the `Key Vault Secrets User` role on that vault. +`apiops publish` checks this before publishing the named value. The check reads the vault and its role assignments with the CLI's own identity (Reader on the subscription covers every read it makes); if it can't read the APIM service, find or read the vault, or list the vault's role assignments, it logs a warning and is skipped. It fails the named value when no role assignment (on an RBAC vault) or access policy (on a vault that uses access policies) it can see grants the APIM identity secret read access, including when the identity has none; a custom role definition it can't read is skipped, which can lead to that failure. Known limitation: it sees only grants made to the APIM identity itself, not through a group it belongs to, so access granted only through a group fails the check. See [Key Vault pre-flight check](../ci-cd/authentication-patterns.md#key-vault-pre-flight-check). + ### Gotcha: Auto-generated named values for loggers When you create a logger in APIM (e.g., for Application Insights), APIM auto-generates a named value to store the credential. These have 24-character hex names (e.g., `<24-char-hex-id>`). During extract, these are captured as artifacts. During publish: diff --git a/docs/troubleshooting/common-errors.md b/docs/troubleshooting/common-errors.md index b4712f0d..56ff3e30 100644 --- a/docs/troubleshooting/common-errors.md +++ b/docs/troubleshooting/common-errors.md @@ -190,7 +190,7 @@ See [Pipeline Recovery](pipeline-recovery.md) for CI/CD-specific recovery steps. **Solution:** -- The CLI automatically retries on 429 responses with the delay specified in the `Retry-After` header +- The CLI automatically retries a 429 response up to 3 times. Like the Azure SDK, it waits for the `retry-after-ms`, `x-ms-retry-after-ms` or `Retry-After` header (seconds or an HTTP date); `0` or a date already passed retries at once. When none of these headers is present or readable, it waits 1, 2 and then 4 seconds instead. A 503 that sends one of these headers is retried after that wait too - If retries are exhausted, reduce the number of resources being processed (use filters for extract or incremental publish) - Wait a few minutes and re-run diff --git a/src/clients/apic-client.ts b/src/clients/apic-client.ts index d7beb764..1abd220c 100644 --- a/src/clients/apic-client.ts +++ b/src/clients/apic-client.ts @@ -3,13 +3,12 @@ /** * Azure API Center control-plane REST client. * - * Implements {@link IApicClient} using `@azure/identity` DefaultAzureCredential. - * Handles pagination, retry/back-off, rate limiting, long-running operations - * (Azure-AsyncOperation/Location polling), and the API definition - * export/import specification actions. + * Implements {@link IApicClient} on top of the shared {@link ArmHttpClient} + * (DefaultAzureCredential auth, retry/back-off, rate limiting, pagination and + * long-running operation polling) and adds the API definition export/import + * specification actions. */ -import { DefaultAzureCredential } from '@azure/identity'; import { IApicClient, ApicSpecification } from './iapic-client.js'; import { ApicResourceType } from '../models/apic-resource-types.js'; import { ApicResourceDescriptor, ApicServiceContext } from '../models/apic-types.js'; @@ -19,59 +18,27 @@ import { buildApicLabel, } from '../lib/apic-uri.js'; import { logger } from '../lib/logger.js'; -import { USER_AGENT } from '../lib/user-agent.js'; -import { getArmHosts } from '../lib/cloud-config.js'; +import { errorMessage } from '../lib/error-message.js'; import { validateServicePreFlight, PreFlightServiceKind } from './preflight.js'; - -/** HTTP error carrying the response status code and optional ARM error code. */ -export class ApicHttpError extends Error { - constructor( - public readonly status: number, - message: string, - public readonly code?: string, - ) { - super(message); - this.name = 'ApicHttpError'; - } -} - -/** Terminal states for an ARM long-running operation (compared lower-case). */ -const TERMINAL_SUCCESS = 'succeeded'; -const TERMINAL_FAILURES = new Set(['failed', 'canceled', 'cancelled']); +import { + ArmHttpClient, + ArmHttpClientOptions, + HttpError, + ArmRequestOptions, + discardBody, + AsyncOperation, + extractAsyncOperation, + paginateArm, + pollArmOperation, + PollArmOperationOptions, + waitUntilGone, +} from './arm-http-client.js'; /** - * Parse a `Retry-After` header (delay-seconds or HTTP-date) into milliseconds. - * Returns undefined when the header is absent or unparsable. + * @deprecated Use {@link HttpError} from `arm-http-client.js`. Kept for + * existing imports and `instanceof` checks. */ -function parseRetryAfterMs(value: string | null): number | undefined { - if (value === null) { - return undefined; - } - const trimmed = value.trim(); - if (/^\d+$/.test(trimmed)) { - return Number(trimmed) * 1000; - } - const date = Date.parse(trimmed); - return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now()); -} - -/** - * True when `url` is an HTTPS URL on a known ARM host (or a subdomain of one), - * i.e. safe to send the ARM bearer token to. - */ -function isArmUrl(url: string): boolean { - try { - const parsed = new URL(url); - return ( - parsed.protocol === 'https:' && - getArmHosts().some( - (host) => parsed.hostname === host || parsed.hostname.endsWith(`.${host}`), - ) - ); - } catch { - return false; - } -} +export { HttpError as ApicHttpError } from './arm-http-client.js'; /** Pre-flight wording for API Center services. */ const APIC_PREFLIGHT_KIND: PreFlightServiceKind = { @@ -81,149 +48,34 @@ const APIC_PREFLIGHT_KIND: PreFlightServiceKind = { }; export class ApicClient implements IApicClient { - private readonly credential: DefaultAzureCredential; - private readonly authScope: string; - private tokenCache: { token: string; expiresOn: number } | null = null; - private tokenPromise: Promise | null = null; + private readonly http: ArmHttpClient; - private static readonly TOKEN_CACHE_BUFFER_MS = 5 * 60 * 1000; - private static readonly MAX_RETRIES = 3; - private static readonly BASE_DELAY_MS = 1000; - private static readonly MAX_DELAY_MS = 30000; /** Deadline for LRO polling — 10 minutes. */ private static readonly ASYNC_POLL_TIMEOUT_MS = 10 * 60 * 1000; /** Default interval between LRO polls when no Retry-After header is present. */ private static readonly ASYNC_POLL_INTERVAL_MS = 5000; - - constructor(authScope = 'https://management.azure.com/.default') { - this.credential = new DefaultAzureCredential(); - this.authScope = authScope; - } - - private async getToken(): Promise { - if ( - this.tokenCache && - this.tokenCache.expiresOn > Date.now() + ApicClient.TOKEN_CACHE_BUFFER_MS - ) { - return this.tokenCache.token; - } - if (!this.tokenPromise) { - this.tokenPromise = this.fetchToken().finally(() => { - this.tokenPromise = null; - }); - } - return this.tokenPromise; - } - - private async fetchToken(): Promise { - const tokenResponse = await this.credential.getToken(this.authScope); - this.tokenCache = { - token: tokenResponse.token, - expiresOn: tokenResponse.expiresOnTimestamp, - }; - return tokenResponse.token; - } - - private delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); - } - - private backoff(attempt: number): number { - const exp = Math.min( - ApicClient.BASE_DELAY_MS * Math.pow(2, attempt), - ApicClient.MAX_DELAY_MS, - ); - return exp + Math.random() * 0.3 * exp; - } + /** API Center returns (rather than throws) a 404 for GET and DELETE. */ + private static readonly REQUEST_OPTIONS: ArmRequestOptions = { + passthrough404Methods: ['GET', 'DELETE'], + }; /** - * Execute an HTTP request with auth, retry on 429/5xx, and graceful 404 for - * GET. `skipAuth` is used for self-authenticating SAS/link URLs. + * @param authScope OAuth2 scope for ARM token requests. + * Defaults to the Azure public cloud scope. + * @param httpOptions Token credential and wait function for the shared ARM + * client. Default to `DefaultAzureCredential` and a `setTimeout` wait. */ - private async request( - url: string, - options: RequestInit = {}, - skipAuth = false, - ): Promise { - const headers = new Headers(options.headers); - if (skipAuth) { - headers.delete('Authorization'); - headers.delete('Proxy-Authorization'); - headers.delete('x-ms-authorization-auxiliary'); - } else { - headers.set('Authorization', `Bearer ${await this.getToken()}`); - headers.set('Content-Type', 'application/json'); - } - headers.set('User-Agent', USER_AGENT); - - let attempt = 0; - const method = options.method ?? 'GET'; - // Strip SAS query before logging. - const logUrl = skipAuth ? url.split('?')[0] : url; - - while (attempt <= ApicClient.MAX_RETRIES) { - try { - logger.debug(`HTTP ${method} ${logUrl}`); - const response = await fetch(url, { ...options, headers }); - - // On the final attempt a 429 falls through and is thrown with its status. - if (response.status === 429 && attempt < ApicClient.MAX_RETRIES) { - const delayMs = - parseRetryAfterMs(response.headers.get('Retry-After')) ?? Math.pow(2, attempt) * 1000; - logger.warn(`Rate limited (429), retrying after ${delayMs / 1000}s`); - await this.delay(delayMs); - attempt++; - continue; - } - - if (response.status >= 500 && response.status < 600) { - if (attempt < ApicClient.MAX_RETRIES) { - const ms = this.backoff(attempt); - logger.warn(`Server error ${response.status}, retrying after ${Math.round(ms)}ms`); - await this.delay(ms); - attempt++; - continue; - } - } - - if (response.status === 404 && (method === 'GET' || method === 'DELETE')) { - return response; - } - - if (!response.ok) { - const errorText = await response.text(); - throw new ApicHttpError( - response.status, - `HTTP ${response.status}: ${errorText}`, - this.parseErrorCode(errorText), - ); - } - - return response; - } catch (error) { - if (error instanceof ApicHttpError && error.status >= 400 && error.status < 500) { - throw error; - } - if (attempt >= ApicClient.MAX_RETRIES) { - throw error; - } - const ms = this.backoff(attempt); - logger.warn(`Request failed: ${(error as Error).message}, retrying after ${Math.round(ms)}ms`); - await this.delay(ms); - attempt++; - } - } - throw new Error('Max retries exceeded'); + constructor(authScope?: string, httpOptions: Omit = {}) { + this.http = new ArmHttpClient({ ...httpOptions, authScope }); } - private parseErrorCode(errorText: string): string | undefined { - try { - const body = JSON.parse(errorText) as { error?: { code?: unknown } }; - const code = body.error?.code; - return typeof code === 'string' ? code : undefined; - } catch { - return undefined; - } + /** Execute an HTTP request through the shared ARM client. */ + private request( + url: string, + init: RequestInit = {}, + options: ArmRequestOptions = {}, + ): Promise { + return this.http.request(url, init, { ...ApicClient.REQUEST_OPTIONS, ...options }); } async *listResources( @@ -232,38 +84,10 @@ export class ApicClient implements IApicClient { parentNameParts: string[], workspace?: string, ): AsyncIterable> { - let url = buildApicListUri(context, type, parentNameParts, workspace); - - while (url) { - let response: Response; - try { - response = await this.request(url); - } catch (error) { - // A missing collection (404) is an empty list, not a failure. - if (error instanceof ApicHttpError && error.status === 404) { - return; - } - throw error; - } - - if (response.status === 404) { - return; - } - - const data = (await response.json()) as { value?: unknown[]; nextLink?: string }; - if (Array.isArray(data.value)) { - for (const item of data.value) { - yield item as Record; - } - } - url = data.nextLink ?? ''; - // nextLink comes from the response; never send the token anywhere but ARM. - if (url && !isArmUrl(url)) { - throw new Error( - `Refusing to follow nextLink to a non-ARM URL: ${url.split('?')[0]}`, - ); - } - } + // A missing collection (404) is an empty list, not a failure. + yield* paginateArm(this.http, buildApicListUri(context, type, parentNameParts, workspace), { + requestOptions: ApicClient.REQUEST_OPTIONS, + }); } async getResource( @@ -273,6 +97,7 @@ export class ApicClient implements IApicClient { const url = buildApicArmUri(context, descriptor); const response = await this.request(url, { method: 'GET' }); if (response.status === 404) { + await discardBody(response); return undefined; } const text = await response.text(); @@ -292,9 +117,14 @@ export class ApicClient implements IApicClient { // Poll any async-operation header, including updates that return 200 while // provisioning continues. A Location header on a 200 is not a polling URL. - const asyncUrl = this.asyncOperationUrl(response, response.status !== 200); - if (asyncUrl) { - await this.pollAsyncOperation(asyncUrl, buildApicLabel(descriptor)); + const operation = extractAsyncOperation(response, { + includeLocation: response.status !== 200, + armHosts: this.http.armHosts, + }); + if (operation) { + // The final state comes from polling, so the PUT body is not read. + await discardBody(response); + await this.pollAsyncOperation(operation, buildApicLabel(descriptor)); const settled = await this.getResource(context, descriptor); return settled ?? {}; } @@ -309,14 +139,25 @@ export class ApicClient implements IApicClient { ): Promise { const url = buildApicArmUri(context, descriptor); const response = await this.request(url, { method: 'DELETE' }); + // Only the status and headers of a DELETE response are used. + await discardBody(response); if (response.status === 404) { return false; } if (response.status === 202) { - // Without a (valid) polling header, poll the resource itself until it - // is gone: 404 is success and a Failed provisioningState fails. - const asyncUrl = this.asyncOperationUrl(response) ?? url; - await this.pollAsyncOperation(asyncUrl, buildApicLabel(descriptor), true); + const operation = extractAsyncOperation(response, { armHosts: this.http.armHosts }); + if (operation) { + await this.pollAsyncOperation(operation, buildApicLabel(descriptor), { treatMissingAsSuccess: true }); + } else { + // Without a (valid) polling header, wait until the resource itself is + // gone: one that is still returned keeps the wait going, whatever its + // state or body, Failed included. The probe checks presence only. + await waitUntilGone(() => this.resourceExists(context, descriptor), buildApicLabel(descriptor), { + timeoutMs: ApicClient.ASYNC_POLL_TIMEOUT_MS, + intervalMs: ApicClient.ASYNC_POLL_INTERVAL_MS, + delay: (ms) => this.http.delay(ms), + }); + } } return true; } @@ -330,19 +171,15 @@ export class ApicClient implements IApicClient { try { response = await this.request(actionUrl, { method: 'POST' }); } catch (error) { - if (error instanceof ApicHttpError && error.status === 404) { + if (error instanceof HttpError && error.status === 404) { return undefined; } logger.warn( - `Failed to export specification for ${buildApicLabel(descriptor)}: ${(error as Error).message}`, + `Failed to export specification for ${buildApicLabel(descriptor)}: ${errorMessage(error)}`, ); return undefined; } - if (response.status === 404) { - return undefined; - } - const text = await response.text(); if (!text.trim()) { return undefined; @@ -356,8 +193,9 @@ export class ApicClient implements IApicClient { // returns the content directly. let content = body.value; if (body.format === 'link') { - const blob = await this.request(body.value, {}, true); + const blob = await this.request(body.value, {}, { skipAuth: true }); if (!blob.ok) { + await discardBody(blob); logger.warn(`Failed to fetch specification link for ${buildApicLabel(descriptor)}`); return undefined; } @@ -382,11 +220,13 @@ export class ApicClient implements IApicClient { specification: { name: spec.name, ...(spec.version ? { version: spec.version } : {}) }, }), }); + // Only the status and headers of the import response are used. + await discardBody(response); if (response.status === 202) { - const asyncUrl = this.asyncOperationUrl(response); - if (asyncUrl) { - await this.pollAsyncOperation(asyncUrl, `${buildApicLabel(descriptor)} (spec import)`); + const operation = extractAsyncOperation(response, { armHosts: this.http.armHosts }); + if (operation) { + await this.pollAsyncOperation(operation, `${buildApicLabel(descriptor)} (spec import)`); } } } @@ -416,6 +256,20 @@ export class ApicClient implements IApicClient { }; } + /** + * GET the resource and report whether it exists: false on 404, true on any + * other response the client returns. The body is released unread, so a + * body that is not JSON still counts as present. + */ + private async resourceExists( + context: ApicServiceContext, + descriptor: ApicResourceDescriptor, + ): Promise { + const response = await this.request(buildApicArmUri(context, descriptor), { method: 'GET' }); + await discardBody(response); + return response.status !== 404; + } + /** Build an action URL (`.../{resource}/{action}?api-version=...`). */ private actionUrl( context: ApicServiceContext, @@ -427,86 +281,18 @@ export class ApicClient implements IApicClient { } /** - * Extract the LRO polling URL from response headers, if any. Prefers - * Azure-AsyncOperation, then Operation-Location, then (when - * `includeLocation`) Location. URLs that are not HTTPS on a known ARM host - * are ignored so the bearer token is never sent elsewhere. - */ - private asyncOperationUrl(response: Response, includeLocation = true): string | undefined { - const asyncUrl = - response.headers.get('Azure-AsyncOperation') ?? - response.headers.get('Operation-Location') ?? - (includeLocation ? response.headers.get('Location') : null); - if (!asyncUrl) { - return undefined; - } - if (!isArmUrl(asyncUrl)) { - logger.warn(`Ignoring async operation URL that is not HTTPS on a known ARM host: ${asyncUrl.split('?')[0]}`); - return undefined; - } - return asyncUrl; - } - - /** - * Poll an ARM async-operation URL until it reaches a terminal state. - * @param treatMissingAsSuccess when true, a 404 during polling (resource gone) - * is treated as success — used for delete operations. + * Poll an ARM long-running operation until it reaches a terminal state. + * @param options `treatMissingAsSuccess` for delete operations. */ - private async pollAsyncOperation( - asyncUrl: string, + private pollAsyncOperation( + operation: AsyncOperation, label: string, - treatMissingAsSuccess = false, + options: Pick = {}, ): Promise { - const deadline = Date.now() + ApicClient.ASYNC_POLL_TIMEOUT_MS; - while (Date.now() < deadline) { - const response = await this.request(asyncUrl, { method: 'GET' }); - if (response.status === 404) { - if (treatMissingAsSuccess) { - return; - } - throw new Error(`Async operation for ${label} not found (404).`); - } - - const retryAfterMs = parseRetryAfterMs(response.headers.get('Retry-After')); - const text = await response.text(); - const status = this.readOperationStatus(text, response.status); - - if (status.toLowerCase() === TERMINAL_SUCCESS) { - return; - } - if (TERMINAL_FAILURES.has(status.toLowerCase())) { - throw new Error(`Async operation for ${label} ${status}: ${text.substring(0, 500)}`); - } - - await this.delay( - retryAfterMs !== undefined && retryAfterMs > 0 - ? retryAfterMs - : ApicClient.ASYNC_POLL_INTERVAL_MS, - ); - } - throw new Error(`Async operation for ${label} timed out after ${ApicClient.ASYNC_POLL_TIMEOUT_MS}ms.`); - } - - /** - * Resolve the operation status from a polling response. ARM async operations - * report `{ status }`; provisioning-style responses report - * `{ properties: { provisioningState } }`. A bare 200 with no status is - * treated as success. - */ - private readOperationStatus(text: string, httpStatus: number): string { - const fallback = httpStatus === 200 ? 'Succeeded' : 'InProgress'; - if (!text.trim()) { - return fallback; - } - try { - const body = JSON.parse(text) as { - status?: unknown; - properties?: { provisioningState?: unknown }; - }; - const state = body.status ?? body.properties?.provisioningState; - return typeof state === 'string' ? state : fallback; - } catch { - return fallback; - } + return pollArmOperation(this.http, operation, label, { + ...options, + timeoutMs: ApicClient.ASYNC_POLL_TIMEOUT_MS, + intervalMs: ApicClient.ASYNC_POLL_INTERVAL_MS, + }); } } diff --git a/src/clients/apim-client.ts b/src/clients/apim-client.ts index 27a7d7cf..9325bf6c 100644 --- a/src/clients/apim-client.ts +++ b/src/clients/apim-client.ts @@ -2,38 +2,40 @@ // Licensed under the MIT license. /** * Azure REST HTTP client implementing IApimClient - * Uses @azure/identity DefaultAzureCredential for auth - * Handles pagination, retry, rate limiting, and long-running operations + * Uses the shared ArmHttpClient (DefaultAzureCredential auth, retry, rate + * limiting, pagination and long-running operation polling) and adds the + * APIM-specific handling on top */ -import { DefaultAzureCredential } from '@azure/identity'; import { IApimClient, ApiSpecDialect } from './iapim-client.js'; import { ApimServiceContext, ResourceDescriptor } from '../models/types.js'; import { RESOURCE_TYPE_METADATA, ResourceType } from '../models/resource-types.js'; import { buildArmUri, buildResourceLabel } from '../lib/resource-uri.js'; import { deriveListPaths, getResourceDescriptorKey } from '../lib/resource-path.js'; import { logger } from '../lib/logger.js'; +import { errorMessage } from '../lib/error-message.js'; import { isWorkspaceScope } from '../lib/workspace-link.js'; -import { USER_AGENT } from '../lib/user-agent.js'; import { formatDuration } from '../lib/format-duration.js'; import { validateServicePreFlight, PreFlightServiceKind } from './preflight.js'; -import { recordRetry, withRetryResource } from '../lib/retry-tracker.js'; - -/** - * Structured HTTP error that carries the response status code. - * Allows callers to branch on status without parsing the message string. - */ -export class HttpError extends Error { - constructor( - public readonly status: number, - message: string, - public readonly code?: string, // APIM error code, e.g. "MethodNotAllowedInPricingTier" - public readonly body?: unknown - ) { - super(message); - this.name = 'HttpError'; - } -} +import { withRetryResource } from '../lib/retry-tracker.js'; +import { + ArmHttpClient, + ArmHttpClientOptions, + HttpError, + ArmRequestOptions, + AsyncOperation, + discardBody, + extractAsyncOperation, + isFailedState, + logRetry, + paginateArm, + pollArmOperation, + readArmState, + waitUntilGone, +} from './arm-http-client.js'; + +/** Re-exported for existing imports from this module; defined in `arm-http-client.js`. */ +export { HttpError } from './arm-http-client.js'; /** * Returns true when the error indicates an association link already exists. @@ -86,48 +88,9 @@ export function stripSourceArmId( return rest; } -/** - * Build a short, human-readable description of a request URL for log output. - * ARM URLs are reduced to the path below the APIM service (e.g. - * `apis/echo/operations/get`); other URLs keep host and path only. - * Query strings are always dropped. - */ -export function describeRequestTarget(url: string): string { - let pathname: string; - let host = ''; - try { - const parsed = new URL(url); - pathname = parsed.pathname; - host = parsed.host; - } catch { - pathname = url.split('?')[0] ?? url; - } - - const match = /\/providers\/Microsoft\.ApiManagement\/service\/[^/]+\/?(.*)$/i.exec(pathname); - if (match) { - return safeDecode(match[1] || 'service'); - } - return safeDecode(`${host}${pathname}`); -} - -function safeDecode(value: string): string { - try { - return decodeURIComponent(value); - } catch { - return value; - } -} - export class ApimClient implements IApimClient { - private credential: DefaultAzureCredential; - private readonly authScope: string; - private tokenCache: { token: string; expiresOn: number } | null = null; - private tokenPromise: Promise | null = null; - - private static readonly TOKEN_CACHE_BUFFER_MS = 5 * 60 * 1000; // 5 minutes - private static readonly MAX_RETRIES = 3; - private static readonly BASE_DELAY_MS = 1000; - private static readonly MAX_DELAY_MS = 30000; + private readonly http: ArmHttpClient; + private static readonly MAX_POLLING_ATTEMPTS = 30; private static readonly POLL_INTERVAL_MS = 2000; /** Deadline for async (LRO) operation polling — 7.5 minutes. */ @@ -138,216 +101,37 @@ export class ApimClient implements IApimClient { private static readonly DELETE_CONFLICT_RETRIES = 3; /** Base delay between DELETE conflict retries (multiplied by attempt number). */ private static readonly DELETE_CONFLICT_RETRY_DELAY_MS = 2000; - /** Known ARM management plane host suffixes for URL validation. */ - private static readonly ARM_HOSTS = [ - 'management.azure.com', - 'management.chinacloudapi.cn', - 'management.usgovcloudapi.net', - 'management.microsoftazure.de', - ]; /** - * @param authScope OAuth2 scope for ARM token requests. - * Defaults to the Azure public cloud scope. + * APIM reports operations blocked by an API's in-progress async operation + * as a transient 409 PessimisticConcurrencyConflict, which is retried. */ - constructor(authScope = 'https://management.azure.com/.default') { - this.credential = new DefaultAzureCredential(); - this.authScope = authScope; - } + private static readonly REQUEST_OPTIONS: ArmRequestOptions = { + isRetryableError: (error) => + error.status === 409 && error.code === 'PessimisticConcurrencyConflict', + }; /** - * Get bearer token for Azure Management API. - * Uses a promise cache to prevent duplicate token requests under concurrency. + * @param authScope OAuth2 scope for ARM token requests. + * Defaults to the Azure public cloud scope. + * @param httpOptions Token credential and wait function for the shared ARM + * client. Default to `DefaultAzureCredential` and a `setTimeout` wait. */ - private async getToken(): Promise { - if (this.tokenCache && this.tokenCache.expiresOn > Date.now() + ApimClient.TOKEN_CACHE_BUFFER_MS) { - return this.tokenCache.token; - } - - if (!this.tokenPromise) { - this.tokenPromise = this.fetchToken().finally(() => { - this.tokenPromise = null; - }); - } - - return this.tokenPromise; - } - - private async fetchToken(): Promise { - const tokenResponse = await this.credential.getToken(this.authScope); - this.tokenCache = { - token: tokenResponse.token, - expiresOn: tokenResponse.expiresOnTimestamp, - }; - return tokenResponse.token; + constructor(authScope?: string, httpOptions: Omit = {}) { + this.http = new ArmHttpClient({ ...httpOptions, authScope }); } /** - * Execute HTTP request with retry logic and rate limiting. - * @param noRetryOn5xx - When true, 5xx responses are returned immediately - * without retrying (e.g. wiki endpoints that return 500 for missing wikis). - * @param skipAuth - When true, skip adding Authorization/Content-Type headers - * (e.g. blob SAS URLs that are self-authenticating). - * @param allowedNonOkStatuses - Status codes the caller will handle itself - * (response returned instead of throwing). 404 is always allowed for GETs. + * Execute an HTTP request through the shared ARM client. APIM reports + * operations blocked by an API's in-progress async operation as a + * transient 409 PessimisticConcurrencyConflict, which is retried; other + * client errors are deterministic. */ - private async request( + private request( url: string, - options: RequestInit = {}, - noRetryOn5xx = false, - skipAuth = false, - allowedNonOkStatuses: readonly number[] = [] + init: RequestInit = {}, + options: ArmRequestOptions = {} ): Promise { - const headers = new Headers(options.headers); - if (!skipAuth) { - const token = await this.getToken(); - headers.set('Authorization', `Bearer ${token}`); - headers.set('Content-Type', 'application/json'); - } else { - headers.delete('Authorization'); - headers.delete('Proxy-Authorization'); - headers.delete('x-ms-authorization-auxiliary'); - } - headers.set('User-Agent', USER_AGENT); - - let attempt = 0; - // For SAS blob URLs the query string contains the sig token — strip it before logging. - const logUrl = skipAuth ? url.split('?')[0] : url; - const method = options.method ?? 'GET'; - const target = `${method} ${describeRequestTarget(logUrl)}`; - const maxAttempts = ApimClient.MAX_RETRIES + 1; - - while (attempt <= ApimClient.MAX_RETRIES) { - try { - logger.debug(`HTTP ${method} ${logUrl}`); - const response = await fetch(url, { ...options, headers }); - - // Handle rate limiting (429) - if (response.status === 429) { - const retryAfter = response.headers.get('Retry-After'); - if (attempt < ApimClient.MAX_RETRIES) { - const delaySeconds = retryAfter ? parseInt(retryAfter, 10) : Math.pow(2, attempt); - this.logRetry( - `Rate limited (429) on ${target} (attempt ${attempt + 1}/${maxAttempts}), ` + - `retrying in ${formatDuration(delaySeconds * 1000)}` - ); - await this.delay(delaySeconds * 1000); - attempt++; - continue; - } - } - - // Handle transient errors (5xx) - if (response.status >= 500 && response.status < 600) { - if (noRetryOn5xx) { - // Caller has opted out of 5xx retries — return the raw response so - // getResource can treat it as "not found" (e.g. wiki endpoints that - // return 500 when the API/product has no wiki). - return response; - } - if (attempt < ApimClient.MAX_RETRIES) { - const delayMs = this.exponentialBackoffWithJitter(attempt); - this.logRetry( - `Server error ${response.status} on ${target} (attempt ${attempt + 1}/${maxAttempts}), ` + - `retrying in ${formatDuration(delayMs)}` - ); - await this.delay(delayMs); - attempt++; - continue; - } - } - - // Handle 404 gracefully for GET operations (default method is GET when unspecified) - if (response.status === 404 && (options.method === 'GET' || !options.method)) { - return response; - } - - // Caller-opt-in: return specific non-OK statuses instead of throwing - // (e.g. 406 from GraphQL `graphql-link` export when the API has no - // downloadable SDL — a valid "no spec available" signal, not an error). - if (!response.ok && allowedNonOkStatuses.includes(response.status)) { - return response; - } - - // Check for error responses - if (!response.ok) { - const errorText = await response.text(); - let errorCode: string | undefined; - let errorBody: unknown; - try { - errorBody = JSON.parse(errorText); - if ( - typeof errorBody === 'object' && errorBody !== null && - 'error' in errorBody && - typeof (errorBody as Record)['error'] === 'object' && - (errorBody as Record)['error'] !== null && - 'code' in ((errorBody as Record)['error'] as Record) && - typeof ((errorBody as Record)['error'] as Record)['code'] === 'string' - ) { - errorCode = ((errorBody as Record)['error'] as Record)['code'] as string; - } - } catch { - // Response body is not JSON — no error code available - } - throw new HttpError(response.status, `HTTP ${response.status}: ${errorText}`, errorCode, errorBody); - } - - return response; - } catch (error) { - // APIM reports operations blocked by an API's in-progress async operation - // as a transient 409. Other client errors are deterministic. - const isPessimisticConcurrencyConflict = - error instanceof HttpError && - error.status === 409 && - error.code === 'PessimisticConcurrencyConflict'; - if ( - error instanceof HttpError && - error.status >= 400 && - error.status < 500 && - !isPessimisticConcurrencyConflict - ) { - throw error; - } - if (attempt >= ApimClient.MAX_RETRIES) { - throw error; - } - const delayMs = this.exponentialBackoffWithJitter(attempt); - this.logRetry( - `Request failed on ${target} (attempt ${attempt + 1}/${maxAttempts}): ` + - `${(error as Error).message}, retrying in ${formatDuration(delayMs)}` - ); - await this.delay(delayMs); - attempt++; - } - } - - throw new Error('Max retries exceeded'); - } - - /** - * Log an intermediate retry. When the retry is attributed to an active - * tracking scope (e.g. a publish operation that reports its retry count on - * the final status line), the message is demoted to debug to keep normal - * output free of interleaved retry noise. - */ - private logRetry(message: string): void { - if (recordRetry()) { - logger.debug(message); - } else { - logger.warn(message); - } - } - - private exponentialBackoffWithJitter(attempt: number): number { - const exponentialDelay = Math.min( - ApimClient.BASE_DELAY_MS * Math.pow(2, attempt), - ApimClient.MAX_DELAY_MS - ); - const jitter = Math.random() * 0.3 * exponentialDelay; - return exponentialDelay + jitter; - } - - private delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); + return this.http.request(url, init, { ...ApimClient.REQUEST_OPTIONS, ...options }); } async *listResources( @@ -390,50 +174,27 @@ export class ApimClient implements IApimClient { } // Handle pagination with nextLink - while (url) { - let response: Response; - try { - response = await this.request(url); - } catch (error) { + yield* paginateArm(this.http, url, { + requestOptions: ApimClient.REQUEST_OPTIONS, + shouldStopOnError: (error) => { // HTTP 400 with MethodNotAllowedInPricingTier means the resource type is not // supported by this APIM pricing tier. Skip it — do not treat as a failure. if (error instanceof HttpError && error.code === 'MethodNotAllowedInPricingTier') { logger.debug(`Skipping resource type ${type} — not available in this pricing tier.`); - return; + return true; } // Workspace-scoped list may return transient 500s on freshly-created workspaces. if (error instanceof HttpError && error.status === 500 && isWorkspaceScoped) { logger.warn(`Workspace list for ${type} returned 500, treating as empty list`); - return; + return true; } - throw error; - } - - // request() returns 404 GET responses directly (non-throwing). + return false; + }, // A 404 on a list endpoint means the resource collection does not exist // for this service instance — treat as empty. - if (!response.ok) { - if (response.status === 404) { - logger.debug(`Resource type ${type} returned HTTP 404, resource collection not found — returning empty list`); - return; - } - - throw new HttpError( - response.status, - `Request failed with HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}` - ); - } - - const data = await response.json() as { value?: unknown[]; nextLink?: string }; - - if (data.value && Array.isArray(data.value)) { - for (const item of data.value) { - yield item as Record; - } - } - - url = data.nextLink ?? ''; - } + onNotFound: () => + logger.debug(`Resource type ${type} returned HTTP 404, resource collection not found — returning empty list`), + }); } async getResource( @@ -465,9 +226,10 @@ export class ApimClient implements IApimClient { const suppress500 = isWiki; try { - const response = await this.request(url, { method: 'GET' }, suppress500); + const response = await this.request(url, { method: 'GET' }, { noRetryOn5xx: suppress500 }); if (response.status === 404 || (suppress500 && response.status >= 500 && response.status < 600)) { + await discardBody(response); return undefined; } @@ -490,7 +252,7 @@ export class ApimClient implements IApimClient { logger.debug(`Resource does not support GET (HTTP 405), treating as not found: ${url}`); return undefined; } - logger.error(`Failed to get resource: ${(error as Error).message}`); + logger.error(`Failed to get resource: ${errorMessage(error)}`); throw error; } } @@ -520,14 +282,20 @@ export class ApimClient implements IApimClient { // updates that return 200 while provisioning continues asynchronously. // Skip polling for association resources that don't support GET (supportsGet: false in metadata). const metadata = RESOURCE_TYPE_METADATA[descriptor.type]; - const asyncUrl = this.extractAsyncOperationUrl(response, response.status !== 200); - if (asyncUrl && metadata.supportsGet) { - return await this.pollAsyncOperation(asyncUrl, context, descriptor); + const operation = extractAsyncOperation(response, { + includeLocation: response.status !== 200, + armHosts: this.http.armHosts, + }); + if (operation && metadata.supportsGet) { + // The final state comes from polling, so the PUT body is not read. + await discardBody(response); + return await this.pollAsyncOperation(operation, context, descriptor); } // Headerless creates may still provision asynchronously. Check status before // reading the body to avoid consuming XML returned by policy endpoints. if (response.status === 201 || response.status === 202) { + await discardBody(response); if (!metadata.supportsGet) { logger.debug(`Skipping provisioning poll for association resource: ${buildResourceLabel(descriptor)}`); return {}; @@ -616,6 +384,8 @@ export class ApimClient implements IApimClient { for (let attempt = 1; ; attempt++) { try { const response = await this.request(url, { method: 'DELETE' }); + // Only the status and headers of a DELETE response are used. + await discardBody(response); if (response.status === 404) { return false; // Already deleted @@ -623,13 +393,11 @@ export class ApimClient implements IApimClient { // Poll for long-running operations if (response.status === 202) { - const asyncUrl = this.extractAsyncOperationUrl(response); - if (asyncUrl) { - await this.pollAsyncOperation(asyncUrl, context, descriptor, { treatMissingAsSuccess: true }); + const operation = extractAsyncOperation(response, { armHosts: this.http.armHosts }); + if (operation) { + await this.pollAsyncOperation(operation, context, descriptor, { treatMissingAsSuccess: true }); } else { - await this.pollProvisioningState(context, descriptor, { - treatMissingAsSuccess: true, - }); + await this.waitForDeletion(context, descriptor); } } @@ -646,11 +414,11 @@ export class ApimClient implements IApimClient { (error.status === 412 || error.code === 'PreconditionFailed'); if (isConflict && attempt < ApimClient.DELETE_CONFLICT_RETRIES) { const delayMs = ApimClient.DELETE_CONFLICT_RETRY_DELAY_MS * attempt; - this.logRetry( + logRetry( `Delete conflict for ${buildResourceLabel(descriptor)} ` + `(attempt ${attempt}/${ApimClient.DELETE_CONFLICT_RETRIES}), retrying in ${formatDuration(delayMs)}` ); - await this.delay(delayMs); + await this.http.delay(delayMs); continue; } throw error; @@ -710,16 +478,16 @@ export class ApimClient implements IApimClient { const response = await this.request( buildExportUrl(exportFormat), {}, - isXmlExport, - false, - allowedNonOkStatuses + { noRetryOn5xx: isXmlExport, allowedNonOkStatuses } ); if (response.status === 404) { + await discardBody(response); return undefined; } if (isGraphQLLink && response.status === 406) { + await discardBody(response); logger.debug( `No graphql-link specification available for ${apiName} (HTTP 406); ` + `GraphQL schema will be sourced from /schemas instead.` @@ -728,6 +496,7 @@ export class ApimClient implements IApimClient { } if (isXmlExport && inlineFormat && response.status >= 500 && response.status < 600) { + await discardBody(response); logger.warn( `APIM returned HTTP ${response.status} exporting ${exportFormat} for ${apiName}; ` + `falling back to inline ${inlineFormat} export.` @@ -744,8 +513,9 @@ export class ApimClient implements IApimClient { } // Fetch the actual spec content from the blob SAS URL (no auth header needed — SAS token is self-authenticating) - const blobResponse = await this.request(blobLink, {}, false, true); + const blobResponse = await this.request(blobLink, {}, { skipAuth: true }); if (!blobResponse.ok) { + await discardBody(blobResponse); logger.warn(`Failed to fetch specification blob for ${apiName}: HTTP ${blobResponse.status}`); return undefined; } @@ -760,7 +530,7 @@ export class ApimClient implements IApimClient { format, }; } catch (error) { - logger.warn(`Failed to get API specification for ${apiName}: ${(error as Error).message}`); + logger.warn(`Failed to get API specification for ${apiName}: ${errorMessage(error)}`); return undefined; } } @@ -780,12 +550,7 @@ export class ApimClient implements IApimClient { try { const response = await this.request(url); if (response.status === 404) { - return undefined; - } - if (!response.ok) { - logger.warn( - `Inline ${inlineFormat} export for ${apiName} failed: HTTP ${response.status}. Specification will be skipped.` - ); + await discardBody(response); return undefined; } @@ -827,7 +592,7 @@ export class ApimClient implements IApimClient { return { content, format }; } catch (error) { logger.warn( - `Inline ${inlineFormat} export for ${apiName} threw: ${(error as Error).message}. Specification will be skipped.` + `Inline ${inlineFormat} export for ${apiName} threw: ${errorMessage(error)}. Specification will be skipped.` ); return undefined; } @@ -908,159 +673,51 @@ export class ApimClient implements IApimClient { return 'yaml'; } - /** - * Extract the async operation URL from ARM LRO response headers. - * Prefers Azure-AsyncOperation over Location. - * Validates the URL points to a known ARM management endpoint to prevent - * leaking the bearer token to an unexpected host. - */ - private extractAsyncOperationUrl( - response: Response, - includeLocation = true - ): string | undefined { - const asyncOpUrl = response.headers.get('Azure-AsyncOperation') - ?? response.headers.get('Operation-Location') - ?? (includeLocation ? response.headers.get('Location') : undefined); - - if (!asyncOpUrl) return undefined; - - // Validate URL host is a known ARM management endpoint - try { - const parsed = new URL(asyncOpUrl); - if (parsed.protocol !== 'https:') { - logger.warn(`Ignoring non-HTTPS async operation URL: ${asyncOpUrl}`); - return undefined; - } - const isArmHost = ApimClient.ARM_HOSTS.some( - (host) => parsed.hostname === host || parsed.hostname.endsWith(`.${host}`) - ); - if (!isArmHost) { - logger.warn( - `Ignoring async operation URL with unexpected host: ${parsed.hostname}` - ); - return undefined; - } - } catch { - logger.warn(`Ignoring malformed async operation URL: ${asyncOpUrl}`); - return undefined; - } - - return asyncOpUrl; - } - /** * Poll an ARM async operation URL until terminal state. - * Used for long-running operations like large API spec imports. - * - * The operation status endpoint returns: - * { "status": "InProgress" | "Succeeded" | "Failed" | "Canceled", ... } + * Used for long-running operations like large API spec imports. An + * Azure-AsyncOperation or Operation-Location status body without a status + * keeps polling. * - * On success, GETs the original resource to return the final state. + * On success, GETs the original resource to return the final state + * (unless `treatMissingAsSuccess`, used for deletes). */ private async pollAsyncOperation( - operationUrl: string, + operation: AsyncOperation, context: ApimServiceContext, descriptor: ResourceDescriptor, options: { treatMissingAsSuccess?: boolean } = {} ): Promise> { const { treatMissingAsSuccess = false } = options; - const label = buildResourceLabel(descriptor); - const deadline = Date.now() + ApimClient.ASYNC_POLL_TIMEOUT_MS; - let pollInterval = ApimClient.ASYNC_POLL_INTERVAL_MS; - - logger.debug(`Polling async operation for ${label}: ${operationUrl}`); - - while (Date.now() < deadline) { - await this.delay(pollInterval); - - const response = await this.request(operationUrl); - - // Honour Retry-After if the service provides it - const retryAfter = response.headers.get('Retry-After'); - if (retryAfter) { - const parsed = parseInt(retryAfter, 10); - if (!isNaN(parsed) && parsed > 0) { - pollInterval = parsed * 1000; - } - } - - // Some Location-style polls respond with 202 (still in progress) - // and may not have a JSON body — keep polling. - if (response.status === 202) { - logger.debug(`Async operation still in progress (HTTP 202) for ${label}`); - continue; - } - - // Try to parse the status body - const text = await response.text(); - if (!text.trim()) { - // Empty body on 200/204 — treat as complete - if (response.ok) { - return treatMissingAsSuccess - ? {} - : await this.getResource(context, descriptor) ?? {}; - } - throw new Error(`Async operation returned empty body with status ${response.status} for ${label}`); - } - - let body: Record; - try { - body = JSON.parse(text) as Record; - } catch { - // Non-JSON on an OK status — treat as complete - if (response.ok) { - return treatMissingAsSuccess - ? {} - : await this.getResource(context, descriptor) ?? {}; - } - throw new Error(`Async operation returned non-JSON with status ${response.status} for ${label}`); - } - - const status = (body.status as string | undefined)?.toLowerCase(); - - if (status === 'succeeded') { - logger.debug(`Async operation succeeded for ${label}`); - if (treatMissingAsSuccess) return {}; - // GET the final resource state - return await this.getResource(context, descriptor) ?? {}; - } - - if (status === 'failed' || status === 'canceled' || status === 'cancelled') { - const error = body.error as Record | undefined; - const code = (error?.code as string) ?? 'UnknownError'; - const message = (error?.message as string) ?? JSON.stringify(body); - throw new Error(`Async operation ${status} for ${label}: [${code}] ${message}`); - } - - // InProgress or other intermediate status — keep polling - logger.debug(`Async operation status: ${status ?? 'unknown'} for ${label}`); - } - - throw new Error( - `Async operation polling timed out after ${ApimClient.ASYNC_POLL_TIMEOUT_MS / 1000}s for ${label}` - ); + await pollArmOperation(this.http, operation, buildResourceLabel(descriptor), { + treatMissingAsSuccess, + timeoutMs: ApimClient.ASYNC_POLL_TIMEOUT_MS, + intervalMs: ApimClient.ASYNC_POLL_INTERVAL_MS, + initialDelayMs: ApimClient.ASYNC_POLL_INTERVAL_MS, + }); + if (treatMissingAsSuccess) return {}; + // GET the final resource state + return await this.getResource(context, descriptor) ?? {}; } + /** + * Poll the resource (through getResource, so types without GET and wiki + * 500s read as missing) after a PUT until its provisioningState is + * terminal, for up to MAX_POLLING_ATTEMPTS polls. The state is compared + * case-insensitively: a resource without a state or with Succeeded + * completes, and Failed, Canceled and Cancelled throw. A missing resource + * keeps polling, since APIM can briefly return 404 while provisioning. + */ private async pollProvisioningState( context: ApimServiceContext, - descriptor: ResourceDescriptor, - options: { treatMissingAsSuccess?: boolean } = {} + descriptor: ResourceDescriptor ): Promise> { - const { treatMissingAsSuccess = false } = options; - for (let attempt = 0; attempt < ApimClient.MAX_POLLING_ATTEMPTS; attempt++) { - await this.delay(ApimClient.POLL_INTERVAL_MS); + await this.http.delay(ApimClient.POLL_INTERVAL_MS); const resource = await this.getResource(context, descriptor); if (!resource) { - if (treatMissingAsSuccess) { - logger.debug( - `Resource no longer present; operation completed: ${buildResourceLabel(descriptor)}` - ); - return {}; - } - // APIM can transiently return 404 while asynchronously provisioning a // resource (e.g. a Key Vault-backed named value). Treat a missing resource // as "not yet visible" and continue polling rather than aborting — the @@ -1072,21 +729,42 @@ export class ApimClient implements IApimClient { continue; } - const properties = resource.properties as Record | undefined; - const provisioningState = properties?.provisioningState as string | undefined; - - if (!provisioningState || provisioningState === 'Succeeded') { - return resource; + const provisioningState = readArmState(resource, { includeStatus: false }); + const state = provisioningState?.toLowerCase(); + + if (state !== undefined && isFailedState(state)) { + throw new Error(`Resource provisioning ${state}: ${JSON.stringify(resource.properties)}`); } - - if (provisioningState === 'Failed') { - throw new Error(`Resource provisioning failed: ${JSON.stringify(properties)}`); + + if (state === undefined || state === 'succeeded') { + return resource; } - - logger.debug(`Polling provisioning state: ${provisioningState} (attempt ${attempt + 1}/${ApimClient.MAX_POLLING_ATTEMPTS})`); + + logger.debug( + `Polling provisioning state: ${provisioningState} ` + + `(attempt ${attempt + 1}/${ApimClient.MAX_POLLING_ATTEMPTS})` + ); } throw new Error('Provisioning state polling timed out'); } + /** + * Wait for the resource to disappear after a DELETE that returned 202 + * without a polling header (see {@link waitUntilGone}): it is read through + * getResource, so types without GET and wiki 500s read as missing. Gives + * up after MAX_POLLING_ATTEMPTS polls POLL_INTERVAL_MS apart, the limit of + * the provisioning-state poll. + */ + private waitForDeletion( + context: ApimServiceContext, + descriptor: ResourceDescriptor + ): Promise { + return waitUntilGone(() => this.getResource(context, descriptor), buildResourceLabel(descriptor), { + timeoutMs: ApimClient.MAX_POLLING_ATTEMPTS * ApimClient.POLL_INTERVAL_MS, + intervalMs: ApimClient.POLL_INTERVAL_MS, + delay: (ms) => this.http.delay(ms), + }); + } + } diff --git a/src/clients/arm-http-client.ts b/src/clients/arm-http-client.ts new file mode 100644 index 00000000..159db63c --- /dev/null +++ b/src/clients/arm-http-client.ts @@ -0,0 +1,970 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +/** + * Shared Azure Resource Manager (ARM) HTTP plumbing for the APIM and API + * Center clients and the Key Vault pre-flight check. + * + * Provides bearer-token acquisition with caching, a `fetch` wrapper with + * retry/back-off for 429, 5xx and network errors, `nextLink` pagination and + * long-running-operation (LRO) polling. URLs returned by ARM (polling URLs, + * `nextLink`) are only followed when they are HTTPS on a known ARM host, so + * the bearer token is never sent elsewhere. + */ + +import { DefaultAzureCredential, getBearerTokenProvider } from '@azure/identity'; +import { getArmHostsForAuthScope, getCloudConfig } from '../lib/cloud-config.js'; +import { errorMessage } from '../lib/error-message.js'; +import { formatDuration } from '../lib/format-duration.js'; +import { logger } from '../lib/logger.js'; +import { recordRetry } from '../lib/retry-tracker.js'; +import { USER_AGENT } from '../lib/user-agent.js'; +import type { IArmHttpClient } from './iarm-http-client.js'; + +/** + * Structured HTTP error that carries the response status code, the ARM error + * code (e.g. `MethodNotAllowedInPricingTier`) and the error body (parsed JSON, + * or the text when it is not JSON). + * Allows callers to branch on status without parsing the message string. + */ +export class HttpError extends Error { + constructor( + public readonly status: number, + message: string, + public readonly code?: string, + public readonly body?: unknown, + ) { + super(message); + this.name = 'HttpError'; + } +} + +/** The parts of an ARM error response body used for {@link HttpError}. */ +export interface ParsedArmError { + /** `error.code`, when it is a string. */ + code?: string; + /** `error.message`, when it is a string. */ + message?: string; + /** The messages of `error.details`, each after its `target` when it has one. */ + details?: string[]; + /** The parsed JSON body; absent when the body is not JSON. */ + body?: unknown; +} + +/** + * The messages of an ARM error's `details`, each after its `target` when it + * has one, followed by the messages of that detail's own `details`. APIM says + * what is wrong there: a "One or more fields contain incorrect values:" error + * has one detail per field. + */ +function readErrorDetails(error: Record): string[] { + if (!Array.isArray(error.details)) { + return []; + } + return error.details.flatMap((detail: unknown) => { + if (typeof detail !== 'object' || detail === null) { + return []; + } + const fields = detail as Record; + const { target, message } = fields; + const own = + typeof message === 'string' && message !== '' + ? [typeof target === 'string' && target !== '' ? `${target}: ${message}` : message] + : []; + return [...own, ...readErrorDetails(fields)]; + }); +} + +/** + * `[] : ; ` for an ARM error object, leaving + * out the parts it doesn't have; undefined when it has none of them. + */ +function describeArmError({ code, message, details = [] }: Omit): string | undefined { + let text = [code !== undefined ? `[${code}]` : undefined, message].filter(Boolean).join(' '); + if (details.length > 0) { + const separator = text === '' ? '' : text.endsWith(':') ? ' ' : ': '; + text += separator + details.join('; '); + } + return text === '' ? undefined : text; +} + +/** + * Parse an ARM error response body. Returns the JSON body (when it is JSON) + * and `error.code`, `error.message` and the `error.details` messages (when + * they are strings). + */ +export function parseArmError(text: string): ParsedArmError { + let body: unknown; + try { + body = JSON.parse(text); + } catch { + return {}; + } + const error = + typeof body === 'object' && body !== null + ? (body as Record).error + : undefined; + const fields = typeof error === 'object' && error !== null ? (error as Record) : {}; + return { + code: typeof fields.code === 'string' ? fields.code : undefined, + message: typeof fields.message === 'string' ? fields.message : undefined, + details: readErrorDetails(fields), + body, + }; +} + +/** + * Longest excerpt of a response body put in an error message: a failed + * response's body when it has no ARM error to describe, or a failed + * operation's status body when it has no error message. + */ +const MAX_ERROR_BODY_EXCERPT_LENGTH = 500; + +/** The first {@link MAX_ERROR_BODY_EXCERPT_LENGTH} characters of `text`, marked when cut. */ +function excerpt(text: string): string { + return text.length > MAX_ERROR_BODY_EXCERPT_LENGTH + ? `${text.substring(0, MAX_ERROR_BODY_EXCERPT_LENGTH)}…` + : text; +} + +/** What a masked SAS signature reads as. */ +const MASKED_SIGNATURE = '***'; + +/** + * A `sig` query parameter, also inside a URL that was percent-encoded once + * or twice (as a link on a firewall's block page can be) or quoted in JSON + * with its `&` escaped (`\u0026`): the separator before it, `sig` and its + * `=`, and the value. The value runs over the characters a signature can have + * as written: base64, `%` escapes, a `\` from JSON escaping, and the `-` and + * `_` of URL-safe base64. It ends at the next escaped separator or at any + * other character, so `[...?sig=abc]` and `sig=abc. Retry` keep their `]` + * and `.`. + */ +const SIG_PARAMETER = + /(^|[?&;\s]|%26|%3F|%2526|%253F|\\u0026)(sig(?:=|%3D|%253D))((?:(?!%26|%2526|\\u0026)[A-Za-z0-9+/=%\\_-])+)/gim; + +/** Escape `text` for use as a literal in a regular expression. */ +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * `text` with the SAS signature masked: the value of any `sig` parameter, + * also in a percent-encoded URL, and the request's own signature wherever + * else it appears (as sent, decoded, encoded once or twice, or JSON-escaped + * with `\/` and `\u002B`), for example in a proxy's or firewall's page that + * quotes or links to the URL. Azure Storage's own authentication errors (for + * example 'Signature did not match') don't quote it, but the body is text + * this client doesn't control. The request's signature is masked on its own + * only when it is one: the base64 of a 32-byte HMAC-SHA256, so a short + * hand-made value can't mask unrelated text. The rest of the body (a + * firewall's block page, a storage error code) is kept, since it says why + * the download failed. + */ +export function maskSasSignature(text: string, url: string): string { + let masked = text.replace(SIG_PARAMETER, `$1$2${MASKED_SIGNATURE}`); + // Read the value as sent and decode only its %XX escapes: URLSearchParams + // would turn an unencoded '+' into a space. + const raw = /[?&]sig=([^&#]*)/i.exec(url)?.[1]; + let signature = raw; + if (raw) { + try { + signature = decodeURIComponent(raw); + } catch { + // A malformed escape: compare the value as sent. + } + } + if (signature && /^[A-Za-z0-9+/]+={0,2}$/.test(signature) && Buffer.from(signature, 'base64').length === 32) { + const forms = new Set([ + signature, + encodeURIComponent(signature), + encodeURIComponent(encodeURIComponent(signature)), + raw ?? signature, + encodeURIComponent(raw ?? signature), + // JSON-escaped: '/' as \/ and '+' as \u002B ('gi' below also matches \u002b). + signature.replace(/\//g, '\\/'), + signature.replace(/\+/g, '\\u002B'), + signature.replace(/\//g, '\\/').replace(/\+/g, '\\u002B'), + ]); + // Longest first, so an encoded form is masked whole. + for (const form of [...forms].sort((x, y) => y.length - x.length)) { + masked = masked.replace(new RegExp(escapeRegExp(form), 'gi'), MASKED_SIGNATURE); + } + } + return masked; +} + +/** + * Build the message for a failed ARM response: + * `HTTP : [] : ; ` from the parsed ARM + * error when it has any of those, in full; otherwise the response body, + * truncated to {@link MAX_ERROR_BODY_EXCERPT_LENGTH} characters. The full body + * stays on {@link HttpError.body}. + */ +function formatArmErrorMessage(status: number, text: string, parsed: ParsedArmError): string { + return `HTTP ${status}: ${describeArmError(parsed) ?? excerpt(text)}`; +} + +/** + * Headers giving a server-requested wait in milliseconds, read before + * `Retry-After` in the same order as the Azure SDK's throttling retry strategy + * (`throttlingRetryStrategy` in `@typespec/ts-http-runtime`). + */ +const RETRY_AFTER_MS_HEADERS = ['retry-after-ms', 'x-ms-retry-after-ms'] as const; + +/** Statuses whose server-requested wait is honoured, as in the Azure SDK. */ +const THROTTLING_STATUSES: readonly number[] = [429, 503]; + +/** A non-empty header value as a finite number, clamped at zero. */ +function parseNonNegativeNumber(value: string): number | undefined { + const trimmed = value.trim(); + if (trimmed === '') { + return undefined; + } + const parsed = Number(trimmed); + return Number.isFinite(parsed) ? Math.max(0, parsed) : undefined; +} + +/** + * Parse a `Retry-After` header (delay-seconds or HTTP-date) into milliseconds, + * as the Azure SDK does: a negative value or a date that has passed is 0. + * Returns undefined when the header is absent or unparsable. + */ +export function parseRetryAfterMs(value: string | null | undefined): number | undefined { + if (value === null || value === undefined) { + return undefined; + } + const seconds = parseNonNegativeNumber(value); + if (seconds !== undefined) { + return seconds * 1000; + } + const date = Date.parse(value.trim()); + return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now()); +} + +/** + * The wait a throttling response (429 or 503) asks for, in milliseconds: + * `retry-after-ms`, then `x-ms-retry-after-ms`, then `Retry-After`, the first + * one that parses. 0 means retry at once. Undefined for other statuses or + * when no header parses. + */ +function readThrottlingDelayMs(response: Response): number | undefined { + if (!THROTTLING_STATUSES.includes(response.status)) { + return undefined; + } + for (const header of RETRY_AFTER_MS_HEADERS) { + const value = response.headers.get(header); + const ms = value === null ? undefined : parseNonNegativeNumber(value); + if (ms !== undefined) { + return ms; + } + } + return parseRetryAfterMs(response.headers.get('Retry-After')); +} + +/** + * A polling response's `Retry-After` in milliseconds, or undefined when it is + * absent, unparsable or 0 (including a date that has passed), so the poll + * keeps its current wait instead of polling at once. + */ +function readPollRetryAfterMs(response: Response): number | undefined { + const retryAfterMs = parseRetryAfterMs(response.headers.get('Retry-After')); + return retryAfterMs !== undefined && retryAfterMs > 0 ? retryAfterMs : undefined; +} + +/** + * True when `url` is an HTTPS URL on one of `armHosts` (or a subdomain of + * one), i.e. safe to send the ARM bearer token to. `armHosts` is required + * (usually the client's {@link ArmHttpClient.armHosts}) so a caller cannot + * accept another cloud's hosts by leaving it out. + */ +export function isArmUrl(url: string, armHosts: readonly string[]): boolean { + try { + const parsed = new URL(url); + return ( + parsed.protocol === 'https:' && + armHosts.some( + (host) => parsed.hostname === host || parsed.hostname.endsWith(`.${host}`), + ) + ); + } catch { + return false; + } +} + +/** + * Build a short, human-readable description of a request URL for log output. + * ARM URLs below an APIM or API Center service are reduced to the path below + * the service (e.g. `apis/echo/operations/get`); other URLs keep host and + * path only. Query strings are always dropped. + */ +export function describeRequestTarget(url: string): string { + let pathname: string; + let host = ''; + try { + const parsed = new URL(url); + pathname = parsed.pathname; + host = parsed.host; + } catch { + pathname = url.split('?')[0] ?? url; + } + + const match = + /\/providers\/(?:Microsoft\.ApiManagement\/service|Microsoft\.ApiCenter\/services)\/[^/]+\/?(.*)$/i + .exec(pathname); + if (match) { + return safeDecode(match[1] || 'service'); + } + return safeDecode(`${host}${pathname}`); +} + +function safeDecode(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +/** Minimal token credential (satisfied by `DefaultAzureCredential`). */ +export interface ArmTokenCredential { + getToken( + scope: string, + ): Promise<{ token: string; expiresOnTimestamp?: number; refreshAfterTimestamp?: number } | null>; +} + +export interface ArmHttpClientOptions { + /** OAuth2 scope for ARM tokens. Defaults to the Azure public cloud scope. */ + authScope?: string; + /** Token credential. Defaults to `DefaultAzureCredential`. */ + credential?: ArmTokenCredential; + /** Wait used for retry back-off and LRO polling. Defaults to a `setTimeout` wait. */ + delay?: (ms: number) => Promise; +} + +/** Per-request behaviour for {@link ArmHttpClient.request}. */ +export interface ArmRequestOptions { + /** + * Send no ARM credentials (`Authorization`, `Proxy-Authorization`, + * `x-ms-authorization-auxiliary`) and no Content-Type, e.g. for + * self-authenticating SAS blob URLs. The query string is dropped from logs. + */ + skipAuth?: boolean; + /** Return 5xx responses immediately instead of retrying them. */ + noRetryOn5xx?: boolean; + /** Non-OK statuses returned to the caller instead of thrown. */ + allowedNonOkStatuses?: readonly number[]; + /** Methods whose 404 is returned instead of thrown. Defaults to GET. */ + passthrough404Methods?: readonly string[]; + /** Return true for a 4xx error that is transient and should be retried. */ + isRetryableError?: (error: HttpError) => boolean; +} + +/** + * Log an intermediate retry. When the retry is attributed to an active + * tracking scope (e.g. a publish operation that reports its retry count on + * the final status line), the message is demoted to debug to keep normal + * output free of interleaved retry noise. + */ +export function logRetry(message: string): void { + if (recordRetry()) { + logger.debug(message); + } else { + logger.warn(message); + } +} + +/** + * Cancel the body of a response that will not be read (one about to be + * retried, or a 404 the caller treats as "not found"), so its connection is + * released now rather than when the response is garbage-collected. A failed + * cancel is logged at debug: nothing depends on it. + */ +export async function discardBody(response: Response): Promise { + try { + await response.body?.cancel(); + } catch (error) { + logger.debug(`Could not cancel an unread response body: ${errorMessage(error)}`); + } +} + +/** The credential type `getBearerTokenProvider` takes. */ +type SdkTokenCredential = Parameters[0]; + +/** + * Adapt an {@link ArmTokenCredential} to the SDK's token credential: a + * missing `expiresOnTimestamp` counts as already expired (fetched again on + * every use), `refreshAfterTimestamp` is passed on, and a null token throws + * with the scope named. + */ +function toSdkCredential(credential: ArmTokenCredential, authScope: string): SdkTokenCredential { + return { + async getToken() { + const tokenResponse = await credential.getToken(authScope); + if (!tokenResponse) { + throw new Error(`Failed to acquire an ARM token for scope ${authScope}`); + } + const { token, expiresOnTimestamp = 0, refreshAfterTimestamp } = tokenResponse; + return { token, expiresOnTimestamp, ...(refreshAfterTimestamp !== undefined ? { refreshAfterTimestamp } : {}) }; + }, + }; +} + +/** HTTP client for ARM: authentication, token caching, retry and back-off. */ +export class ArmHttpClient implements IArmHttpClient { + private static readonly MAX_RETRIES = 3; + private static readonly BASE_DELAY_MS = 1000; + private static readonly MAX_DELAY_MS = 30000; + + readonly authScope: string; + /** + * ARM hosts this client follows polling URLs and `nextLink` to: the host + * of its own cloud (from `authScope`), or every known cloud's when the + * scope is not a known cloud's. + */ + readonly armHosts: readonly string[]; + private readonly tokenProvider: () => Promise; + private readonly wait: (ms: number) => Promise; + + constructor(options: ArmHttpClientOptions = {}) { + this.authScope = options.authScope ?? getCloudConfig('public').authScope; + this.armHosts = getArmHostsForAuthScope(this.authScope); + this.tokenProvider = getBearerTokenProvider( + toSdkCredential(options.credential ?? new DefaultAzureCredential(), this.authScope), + this.authScope, + ); + this.wait = options.delay ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); + } + + /** + * Get a bearer token for the client's scope, through `@azure/identity`'s + * `getBearerTokenProvider` (the Azure SDK's token cycler): the token is + * cached and one refresh is shared by concurrent callers. From two minutes + * before expiry (or the credential's `refreshAfterTimestamp`) it is + * refreshed in the background while the current token is still returned; + * within a second of expiry callers wait for the new one. + */ + getToken(): Promise { + return this.tokenProvider(); + } + + /** Wait `ms` milliseconds (through the `delay` option when set). */ + delay(ms: number): Promise { + return this.wait(ms); + } + + /** Exponential back-off with up to 30% jitter, capped at 30 seconds. */ + private static backoff(attempt: number): number { + const exponentialDelay = Math.min( + ArmHttpClient.BASE_DELAY_MS * Math.pow(2, attempt), + ArmHttpClient.MAX_DELAY_MS, + ); + const jitter = Math.random() * 0.3 * exponentialDelay; + return exponentialDelay + jitter; + } + + /** + * Execute an HTTP request with authentication and retries. + * + * Up to `MAX_RETRIES` (3) retries: + * - 429 waits as long as the response asks: `retry-after-ms`, then + * `x-ms-retry-after-ms`, then `Retry-After` (seconds or an HTTP date), + * the first that parses. 0 or a date that has passed retries at once; with + * no header that parses, the waits are 1, 2 and then 4 seconds. + * - 503 waits as long as those headers ask, when one parses; other 5xx + * responses, and a 503 without one, back off exponentially with jitter. + * - Network errors back off exponentially with jitter. + * Other non-OK responses throw {@link HttpError}, except a 404 for + * `passthrough404Methods` and `allowedNonOkStatuses`, which are returned. + */ + async request( + url: string, + init: RequestInit = {}, + options: ArmRequestOptions = {}, + ): Promise { + const { + skipAuth = false, + noRetryOn5xx = false, + allowedNonOkStatuses = [], + passthrough404Methods = ['GET'], + isRetryableError, + } = options; + + const headers = new Headers(init.headers); + if (!skipAuth) { + const token = await this.getToken(); + headers.set('Authorization', `Bearer ${token}`); + headers.set('Content-Type', 'application/json'); + } else { + headers.delete('Authorization'); + headers.delete('Proxy-Authorization'); + headers.delete('x-ms-authorization-auxiliary'); + } + headers.set('User-Agent', USER_AGENT); + + let attempt = 0; + // For SAS blob URLs the query string contains the sig token — strip it before logging. + const logUrl = skipAuth ? url.split('?')[0] : url; + const method = (init.method ?? 'GET').toUpperCase(); + const target = `${method} ${describeRequestTarget(logUrl)}`; + const maxRetries = ArmHttpClient.MAX_RETRIES; + const maxAttempts = maxRetries + 1; + + while (attempt <= maxRetries) { + try { + logger.debug(`HTTP ${method} ${logUrl}`); + const response = await fetch(url, { ...init, headers }); + + // Rate limiting (429). On the final attempt it falls through and is + // thrown with its status. Without a usable header it waits 1, 2 and + // then 4 seconds (the Azure SDK does not retry this case). + if (response.status === 429 && attempt < maxRetries) { + const delayMs = readThrottlingDelayMs(response) ?? Math.pow(2, attempt) * 1000; + logRetry( + `Rate limited (429) on ${target} (attempt ${attempt + 1}/${maxAttempts}), ` + + `retrying in ${formatDuration(delayMs)}`, + ); + await discardBody(response); + await this.delay(delayMs); + attempt++; + continue; + } + + // Transient server errors (5xx) + if (response.status >= 500 && response.status < 600) { + if (noRetryOn5xx) { + return response; + } + if (attempt < maxRetries) { + // A 503 that sends a wait gets that wait, as in the Azure SDK. + const delayMs = readThrottlingDelayMs(response) ?? ArmHttpClient.backoff(attempt); + logRetry( + `Server error ${response.status} on ${target} (attempt ${attempt + 1}/${maxAttempts}), ` + + `retrying in ${formatDuration(delayMs)}`, + ); + await discardBody(response); + await this.delay(delayMs); + attempt++; + continue; + } + } + + if (response.status === 404 && passthrough404Methods.includes(method)) { + return response; + } + + if (!response.ok && allowedNonOkStatuses.includes(response.status)) { + return response; + } + + if (!response.ok) { + // A SAS download's error body is text the CLI doesn't control: a + // proxy's or firewall's page can quote or link to the URL, signature + // included. Mask it (maskSasSignature), and name the URL without its + // query, since the body alone may not say which host failed. + const errorText = skipAuth ? maskSasSignature(await response.text(), url) : await response.text(); + const parsed = parseArmError(errorText); + throw new HttpError( + response.status, + skipAuth + ? `HTTP ${response.status} from ${logUrl}${errorText ? `: ${excerpt(errorText)}` : ''}` + : formatArmErrorMessage(response.status, errorText, parsed), + parsed.code, + // A non-JSON body is kept as text, since the message may truncate it. + parsed.body ?? (errorText || undefined), + ); + } + + return response; + } catch (error) { + // Client errors are deterministic unless the caller says otherwise. + if ( + error instanceof HttpError && + error.status >= 400 && + error.status < 500 && + !isRetryableError?.(error) + ) { + throw error; + } + if (attempt >= maxRetries) { + throw error; + } + const delayMs = ArmHttpClient.backoff(attempt); + logRetry( + `Request failed on ${target} (attempt ${attempt + 1}/${maxAttempts}): ` + + `${errorMessage(error)}, retrying in ${formatDuration(delayMs)}`, + ); + await this.delay(delayMs); + attempt++; + } + } + + // Unreachable: the final attempt either returns or throws above. + throw new Error('Max retries exceeded'); + } +} + +/* ------------------------------------------------------------------ */ +/* Pagination */ +/* ------------------------------------------------------------------ */ + +export interface PaginateArmOptions { + /** + * Request options applied to every page request. Options that would return + * an error response instead of throwing it are not accepted, so every + * response other than a 404 is a page. + */ + requestOptions?: Omit; + /** + * Called when a page request throws. Return true to end the listing + * quietly (items already yielded stand); otherwise the error is rethrown. + */ + shouldStopOnError?: (error: unknown) => boolean; + /** Called when a page returns 404 (collection not found) before ending the listing. */ + onNotFound?: () => void; +} + +/** + * Yield the items of an ARM list, following `nextLink`. A 404 ends the + * listing (empty collection). A `nextLink` that is not HTTPS on the client's + * ARM host throws instead of being followed with the bearer token. + */ +export async function* paginateArm( + http: ArmHttpClient, + url: string, + options: PaginateArmOptions = {}, +): AsyncIterable> { + let next: string | undefined = url; + + while (next) { + let response: Response; + try { + response = await http.request(next, {}, options.requestOptions); + } catch (error) { + if (options.shouldStopOnError?.(error)) { + return; + } + throw error; + } + + if (response.status === 404) { + await discardBody(response); + options.onNotFound?.(); + return; + } + + // Other non-OK statuses were thrown by the request (requestOptions cannot + // allow them), so this is a page. + + const data = (await response.json()) as { value?: unknown[]; nextLink?: string }; + if (Array.isArray(data.value)) { + for (const item of data.value) { + yield item as Record; + } + } + + next = data.nextLink || undefined; + if (next && !isArmUrl(next, http.armHosts)) { + throw new Error(`Refusing to follow nextLink to a non-ARM URL: ${next.split('?')[0]}`); + } + } +} + +/* ------------------------------------------------------------------ */ +/* Long-running operations */ +/* ------------------------------------------------------------------ */ + +/** + * Read the state an ARM response body reports: `properties.provisioningState` + * or, with `includeStatus` (async-operation status bodies), the top-level + * `status` when it is present, else `properties.provisioningState`. Returns + * the state as reported (compare it case-insensitively), or undefined when + * the body is not an object or the field read is not a string. + */ +export function readArmState(body: unknown, options: { includeStatus: boolean }): string | undefined { + if (typeof body !== 'object' || body === null) { + return undefined; + } + const record = body as Record; + const properties = record.properties as Record | undefined; + const state = options.includeStatus + ? record.status ?? properties?.provisioningState + : properties?.provisioningState; + return typeof state === 'string' ? state : undefined; +} + +/** True for a lower-cased terminal failure state: `failed`, `canceled` or `cancelled`. */ +export function isFailedState(state: string): boolean { + return state === 'failed' || state === 'canceled' || state === 'cancelled'; +} + +/** + * True when a Location poll body looks like an operation-status body rather + * than the resource: it carries an `error` object, or has no non-empty + * `properties` object (absent, null or `{}`). + */ +function looksLikeOperationStatus(body: Record): boolean { + if (typeof body.error === 'object' && body.error !== null) { + return true; + } + const properties = body.properties; + return typeof properties !== 'object' || properties === null || Object.keys(properties).length === 0; +} + +/** Response header an ARM long-running operation's polling URL came from. */ +export type AsyncOperationHeader = 'Azure-AsyncOperation' | 'Operation-Location' | 'Location'; + +/** Polling URL of an ARM long-running operation and the header it came from. */ +export interface AsyncOperation { + url: string; + header: AsyncOperationHeader; +} + +/** + * Extract the async-operation polling URL from ARM LRO response headers. + * Tries Azure-AsyncOperation, then Operation-Location, then (when + * `includeLocation`, the default) Location, and returns the first header + * with a usable URL. An empty header is skipped; a URL that is malformed, + * not HTTPS or not on one of `armHosts` (the polling client's + * {@link ArmHttpClient.armHosts}, required so a caller cannot widen the + * polling client's cloud) is skipped with a warning, so the bearer token is + * never sent to an unexpected host. Returns undefined when no header has a + * usable URL. + */ +export function extractAsyncOperation( + response: Response, + options: { includeLocation?: boolean; armHosts: readonly string[] }, +): AsyncOperation | undefined { + const { includeLocation = true, armHosts } = options; + const headers: readonly AsyncOperationHeader[] = includeLocation + ? ['Azure-AsyncOperation', 'Operation-Location', 'Location'] + : ['Azure-AsyncOperation', 'Operation-Location']; + for (const header of headers) { + const url = response.headers.get(header); + if (url && isUsablePollingUrl(url, armHosts)) { + return { url, header }; + } + } + return undefined; +} + +/** + * True when `url` can be polled with the ARM bearer token: a well-formed + * HTTPS URL on one of `armHosts`. Otherwise logs why it is ignored. + */ +function isUsablePollingUrl(url: string, armHosts: readonly string[]): boolean { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + logger.warn(`Ignoring malformed async operation URL: ${url}`); + return false; + } + if (parsed.protocol !== 'https:') { + logger.warn(`Ignoring non-HTTPS async operation URL: ${url}`); + return false; + } + if (!isArmUrl(url, armHosts)) { + logger.warn(`Ignoring async operation URL with unexpected host: ${parsed.hostname}`); + return false; + } + return true; +} + +/** + * True when `operation` polls an async-operation status endpoint + * (Azure-AsyncOperation or Operation-Location), whose body reports the + * operation's `status`; a Location URL may return the resource instead. + * {@link pollArmOperation} keeps polling a status endpoint's JSON body that + * has no status yet. + */ +export function isOperationStatusEndpoint(operation: AsyncOperation): boolean { + return operation.header !== 'Location'; +} + +export interface PollArmOperationOptions { + /** Treat a 404 while polling as success (delete operations). Otherwise it fails. */ + treatMissingAsSuccess?: boolean; + /** Give up after this long. */ + timeoutMs: number; + /** + * Wait between polls until a poll response sends a positive Retry-After; + * from then on the last such Retry-After is used. + */ + intervalMs: number; + /** Wait before the first poll. Defaults to 0 (poll immediately). */ + initialDelayMs?: number; +} + +/** + * Poll an ARM long-running operation's polling URL until it reaches a + * terminal state. The state is read case-insensitively from `status`, else + * `properties.provisioningState`, for an async-operation status endpoint. + * A Location URL may return the resource itself, so its state is read from + * `properties.provisioningState`; its top-level `status` is used only to + * detect a failure, when the body looks like an operation-status body (it + * has an `error` object, or no non-empty `properties`) and that status is + * `Failed`, `Canceled` or `Cancelled` (see {@link readArmState}): + * - `Succeeded`, or a 2xx (other than 202) without a state → resolves, + * except a JSON body without a state from an async-operation status + * endpoint (Azure-AsyncOperation or Operation-Location, see + * {@link isOperationStatusEndpoint}), which keeps polling since the + * operation has not reported a terminal state yet (an empty or non-JSON + * 2xx still resolves); + * - `Failed` / `Canceled` / `Cancelled` → throws with the ARM error; + * - 202 or any other state → keeps polling. A positive Retry-After sets the + * wait before every later poll until another one replaces it; a missing, + * unparsable or 0 value keeps the current wait, as in `@azure/core-lro`. + * A 404 resolves when `treatMissingAsSuccess`, otherwise throws. + * + * A delete without a polling URL waits for the resource itself to disappear + * with {@link waitUntilGone} instead. + */ +export async function pollArmOperation( + http: ArmHttpClient, + operation: AsyncOperation, + label: string, + options: PollArmOperationOptions, +): Promise { + const { treatMissingAsSuccess = false, timeoutMs, intervalMs } = options; + const operationUrl = operation.url; + const requireStatus = isOperationStatusEndpoint(operation); + const deadline = Date.now() + timeoutMs; + let waitMs = options.initialDelayMs ?? 0; + let pollIntervalMs = intervalMs; + + logger.debug(`Polling async operation for ${label}: ${operationUrl}`); + + while (Date.now() < deadline) { + if (waitMs > 0) { + await http.delay(waitMs); + } + + const response = await http.request(operationUrl); + pollIntervalMs = readPollRetryAfterMs(response) ?? pollIntervalMs; + waitMs = pollIntervalMs; + + if (response.status === 404) { + await discardBody(response); + if (treatMissingAsSuccess) { + logger.debug(`Async operation target no longer present for ${label}; treating as complete`); + return; + } + throw new Error(`Async operation for ${label} not found (404).`); + } + + // Location-style polls respond with 202 while still in progress and may + // not have a JSON body — keep polling. + if (response.status === 202) { + await discardBody(response); + logger.debug(`Async operation still in progress (HTTP 202) for ${label}`); + continue; + } + + const text = await response.text(); + let body: Record = {}; + let isJson = false; + if (text.trim()) { + try { + const parsed: unknown = JSON.parse(text); + isJson = true; + // JSON that is not an object (e.g. `null`) has no status. + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + body = parsed as Record; + } + } catch { + // Non-JSON on an OK status — treat as complete. + } + } + + // A Location URL may return the resource itself, whose own top-level + // `status` (if it has one) is not the operation's status, or an + // operation-status body. A body that looks like the latter (see + // looksLikeOperationStatus) and reports a failed or canceled `status` + // means the operation failed. + let status = readArmState(body, { includeStatus: requireStatus })?.toLowerCase(); + if (!requireStatus && status === undefined && looksLikeOperationStatus(body)) { + const reported = readArmState(body, { includeStatus: true })?.toLowerCase(); + if (reported !== undefined && isFailedState(reported)) { + status = reported; + } + } + + if (status !== undefined && isFailedState(status)) { + const error = + typeof body.error === 'object' && body.error !== null + ? (body.error as Record) + : undefined; + const code = typeof error?.code === 'string' ? error.code : 'UnknownError'; + const message = + typeof error?.message === 'string' ? error.message : excerpt(JSON.stringify(body)); + const description = describeArmError({ code, message, details: error ? readErrorDetails(error) : [] }); + throw new Error(`Async operation ${status} for ${label}: ${description}`); + } + + if (status === undefined && requireStatus && isJson) { + logger.debug(`Async operation status body has no status (HTTP ${response.status}) for ${label}; still polling`); + continue; + } + + if (status === undefined || status === 'succeeded') { + logger.debug(`Async operation succeeded for ${label}`); + return; + } + + logger.debug(`Async operation status: ${status} for ${label}`); + } + + throw new Error(`Async operation polling timed out after ${timeoutMs / 1000}s for ${label}`); +} + +export interface WaitUntilGoneOptions { + /** Give up after this long. */ + timeoutMs: number; + /** Wait before each probe. */ + intervalMs: number; + /** The wait between probes (usually {@link ArmHttpClient.delay}). */ + delay: (ms: number) => Promise; +} + +/** + * Wait for a resource to disappear after a DELETE that returned 202 without + * a polling URL. `probe` either reads the resource (returning its body, or + * undefined once it is gone) or only checks for it (returning true while it + * exists, false once it is gone). A resource that is still there keeps the + * wait going whatever its state, `Failed` or `Canceled` included, since a + * resource in such a state is often the reason it is being deleted. + * + * Waits `intervalMs` before each probe. The last wait is shortened so the + * final probe runs at the deadline; if the resource is still there, throws a + * "not deleted within" error. Errors from `probe` are thrown unchanged. + */ +export async function waitUntilGone( + probe: () => Promise | undefined>, + label: string, + options: WaitUntilGoneOptions, +): Promise { + const { timeoutMs, intervalMs, delay } = options; + const deadline = Date.now() + timeoutMs; + + for (let remainingMs = timeoutMs; remainingMs > 0; remainingMs = deadline - Date.now()) { + await delay(Math.min(intervalMs, remainingMs)); + + const resource = await probe(); + if (resource === undefined || resource === false) { + logger.debug(`Resource no longer present; operation completed: ${label}`); + return; + } + + logger.debug( + resource === true + ? `Waiting for ${label} to be deleted; resource still present` + : `Waiting for ${label} to be deleted; provisioning state: ` + + `${readArmState(resource, { includeStatus: false }) ?? 'none (resource still present)'}`, + ); + } + + throw new Error( + `${label} was not deleted within ${formatDuration(timeoutMs)}: ` + + 'the DELETE was accepted but the resource is still present.', + ); +} diff --git a/src/clients/iarm-http-client.ts b/src/clients/iarm-http-client.ts new file mode 100644 index 00000000..8e5bc2c0 --- /dev/null +++ b/src/clients/iarm-http-client.ts @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +/** + * IArmHttpClient — abstraction over authenticated ARM requests, implemented + * by ArmHttpClient. Lets the Key Vault pre-flight check be tested without a + * credential or network. + */ + +import type { ArmRequestOptions } from './arm-http-client.js'; + +export interface IArmHttpClient { + /** Get a (cached) bearer token for the client's ARM scope. */ + getToken(): Promise; + + /** + * Send an authenticated ARM request with retries. Non-OK responses throw + * `HttpError`, except those the options (or a GET 404) let through. + */ + request(url: string, init?: RequestInit, options?: ArmRequestOptions): Promise; +} diff --git a/src/clients/preflight.ts b/src/clients/preflight.ts index 66b19ee8..1fbb0fdf 100644 --- a/src/clients/preflight.ts +++ b/src/clients/preflight.ts @@ -11,6 +11,7 @@ */ import { logger } from '../lib/logger.js'; +import { HttpError, discardBody } from './arm-http-client.js'; /** ARM api-version for the resource group GET. */ const RESOURCE_GROUP_API_VERSION = '2021-04-01'; @@ -37,7 +38,7 @@ export interface PreFlightServiceKind { /** * Issue a GET and return the response. Must return 404 responses and throw - * an error carrying a numeric `status` for other non-2xx responses. + * an {@link HttpError} for other non-2xx responses. */ export type PreFlightGet = (url: string) => Promise; @@ -61,6 +62,8 @@ export async function validateServicePreFlight( let rgResponse: Response | undefined; try { rgResponse = await get(rgUrl); + // Only the status is used. + await discardBody(rgResponse); } catch (error) { if (getHttpStatus(error) === 403) { logger.debug( @@ -85,6 +88,8 @@ export async function validateServicePreFlight( } catch (error) { throwPreFlightHttpError(error, serviceLabel, kind.roleTarget); } + // Only the status is used. + await discardBody(serviceResponse); if (serviceResponse.status === 404) { if (!rgResponse) { // The resource group check was skipped, so a 404 here can also mean @@ -122,11 +127,10 @@ function throwPreFlightHttpError(error: unknown, resourceLabel: string, roleTarg { cause: error } ); } - throw error as Error; + throw error; } -/** Status code of an HTTP error thrown by either client (`HttpError`, `ApicHttpError`). */ +/** Status code of an {@link HttpError} (thrown by both clients), else undefined. */ function getHttpStatus(error: unknown): number | undefined { - const status = error instanceof Error ? (error as { status?: unknown }).status : undefined; - return typeof status === 'number' ? status : undefined; + return error instanceof HttpError ? error.status : undefined; } diff --git a/src/lib/cloud-config.ts b/src/lib/cloud-config.ts index a84968d2..e3793640 100644 --- a/src/lib/cloud-config.ts +++ b/src/lib/cloud-config.ts @@ -86,6 +86,19 @@ export function getArmHosts(): string[] { return Object.values(CLOUD_CONFIGS).map((config) => new URL(config.armBaseUrl).hostname); } +/** + * Host name of the ARM endpoint of the cloud whose auth scope is `authScope` + * (matched case-insensitively), e.g. `['management.usgovcloudapi.net']`. + * Falls back to {@link getArmHosts} (every known cloud) when the scope does + * not belong to a known cloud. + */ +export function getArmHostsForAuthScope(authScope: string): string[] { + const config = Object.values(CLOUD_CONFIGS).find( + (candidate) => candidate.authScope.toLowerCase() === authScope.toLowerCase(), + ); + return config ? [new URL(config.armBaseUrl).hostname] : getArmHosts(); +} + /** * Build the APIM ARM base URL for a given cloud, subscription, resource group, * and service name. Replaces the hardcoded buildBaseUrl functions in command files. diff --git a/src/lib/error-message.ts b/src/lib/error-message.ts new file mode 100644 index 00000000..d93b1a41 --- /dev/null +++ b/src/lib/error-message.ts @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +/** + * Message text of a caught value for log and error output. + */ + +/** The `message` of an `Error`, or the value converted to a string otherwise. */ +export function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/services/keyvault-checker.ts b/src/services/keyvault-checker.ts index 271ee451..33b8d9a1 100644 --- a/src/services/keyvault-checker.ts +++ b/src/services/keyvault-checker.ts @@ -9,15 +9,18 @@ * 2. Locate the Key Vault resource in the subscription * 3. Check RBAC role assignments or access policies * - * This check is best-effort when infrastructure queries fail (ARM token, - * vault in another subscription, etc.) — a warning is logged and the check - * is skipped. Hard errors are raised only for definitive misconfigurations - * such as "APIM has no managed identity" or "no matching RBAC / access policy". + * ARM calls go through the shared ArmHttpClient (retry on 429/5xx/network + * errors). This check is best-effort when infrastructure queries fail (ARM + * token, vault in another subscription, etc.) — a warning is logged and the + * check is skipped. Hard errors are raised only for definitive + * misconfigurations such as "APIM has no managed identity" or "no matching + * RBAC / access policy". */ -import { DefaultAzureCredential } from '@azure/identity'; import { logger } from '../lib/logger.js'; -import { USER_AGENT } from '../lib/user-agent.js'; +import { errorMessage } from '../lib/error-message.js'; +import { ArmHttpClient, HttpError, discardBody } from '../clients/arm-http-client.js'; +import type { IArmHttpClient } from '../clients/iarm-http-client.js'; /* ------------------------------------------------------------------ */ /* ARM API versions */ @@ -69,41 +72,60 @@ export interface KeyVaultCheckContext { authScope: string; } +/** Creates the ARM client for the target cloud's auth scope (injectable for testing). */ +export type KeyVaultArmClientFactory = (authScope: string) => IArmHttpClient; + /** Minimal response shape from an ARM HTTP call. */ -export interface ArmResponse { +interface ArmResponse { status: number; json(): Promise; } -/** Injectable ARM HTTP call — production uses `fetch`, tests supply a stub. */ -export type ArmRequestFn = (url: string, token: string) => Promise; - -/** Provides ARM bearer tokens. */ -export interface TokenProvider { - getToken(scopes: string | string[]): Promise<{ token: string }>; -} - -/** Factory for creating token providers (injectable for testing). */ -export type TokenProviderFactory = () => TokenProvider; - /* ------------------------------------------------------------------ */ /* Default production implementations */ /* ------------------------------------------------------------------ */ -function defaultTokenProviderFactory(): TokenProvider { - return new DefaultAzureCredential(); +/** + * ARM clients created by the default factory, one per auth scope, so every + * Key Vault check in a run shares a credential and its cached token. + */ +const defaultArmClients = new Map(); + +function defaultArmClientFactory(authScope: string): IArmHttpClient { + let client = defaultArmClients.get(authScope); + if (!client) { + client = new ArmHttpClient({ authScope }); + defaultArmClients.set(authScope, client); + } + return client; +} + +/** + * GET an ARM URL. Non-OK responses (thrown by the client as `HttpError` + * once retries are exhausted) are returned as their status so callers can + * warn and skip; other errors (e.g. network failures) are thrown. Callers + * read the body only of a 200, so any other returned response (e.g. the 404 + * the client passes through for GET) has its body released here. + */ +async function armGet(armClient: IArmHttpClient, url: string): Promise { + try { + const response = await armClient.request(url, { headers: { Accept: 'application/json' } }); + if (response.status !== 200) { + await discardBody(response); + return withoutBody(response.status); + } + return { status: response.status, json: () => response.json() as Promise }; + } catch (error) { + if (error instanceof HttpError) { + return withoutBody(error.status); + } + throw error; + } } -async function defaultArmRequest(url: string, token: string): Promise { - const headers = new Headers(); - headers.set('Authorization', `Bearer ${token}`); - headers.set('Accept', 'application/json'); - headers.set('User-Agent', USER_AGENT); - const response = await fetch(url, { headers }); - return { - status: response.status, - json: () => response.json() as Promise, - }; +/** A non-200 result: callers read the body only of a 200, so it has none. */ +function withoutBody(status: number): ArmResponse { + return { status, json: () => Promise.resolve(undefined) }; } /* ------------------------------------------------------------------ */ @@ -122,15 +144,14 @@ async function defaultArmRequest(url: string, token: string): Promise { /* ---- 1. Parse vault name & secret name from the URI ---- */ let vaultName: string; @@ -151,14 +172,17 @@ export async function checkKeyVaultSecretAccess( } /* ---- 2. Acquire ARM token ---- */ - const credential = tokenProviderFactory(); - let token: string; + let armClient: IArmHttpClient; try { - token = (await credential.getToken(apimContext.authScope)).token; + // Created and fetched up front so a missing or misconfigured credential + // skips the check with one clear warning; the client caches the token + // for the requests below. + armClient = armClientFactory(apimContext.authScope); + await armClient.getToken(); } catch (error) { logger.warn( `Unable to acquire ARM token — skipping Key Vault pre-flight check. ` + - `(Error: ${(error as Error).message})`, + `(Error: ${errorMessage(error)})`, ); return; } @@ -167,8 +191,7 @@ export async function checkKeyVaultSecretAccess( const { principalId, identityLabel } = await resolveApimPrincipal( apimContext, identityClientId, - token, - armRequest, + armClient, ); if (!principalId) return; // soft-skip — warning already logged @@ -181,8 +204,7 @@ export async function checkKeyVaultSecretAccess( apimContext.armBaseUrl, apimContext.subscriptionId, vaultName, - token, - armRequest, + armClient, ); if (!vault) return; // soft-skip — warning already logged @@ -191,16 +213,16 @@ export async function checkKeyVaultSecretAccess( .properties as Record; if (vaultProps?.enableRbacAuthorization === true) { - await checkRbacAccess( + const confirmed = await checkRbacAccess( apimContext.armBaseUrl, vault.resourceId, principalId, identityLabel, vaultName, secretName, - token, - armRequest, + armClient, ); + if (!confirmed) return; // soft-skip — warning already logged } else { checkAccessPolicies( vaultProps, @@ -233,8 +255,7 @@ export async function checkKeyVaultSecretAccess( async function resolveApimPrincipal( ctx: KeyVaultCheckContext, identityClientId: string | undefined, - token: string, - armRequest: ArmRequestFn, + armClient: IArmHttpClient, ): Promise<{ principalId: string | undefined; identityLabel: string }> { const identityLabel = identityClientId ? `user-assigned identity '${identityClientId}'` @@ -248,7 +269,7 @@ async function resolveApimPrincipal( let apimJson: Record; try { - const resp = await armRequest(apimUrl, token); + const resp = await armGet(armClient, apimUrl); if (resp.status !== 200) { logger.warn( `Failed to fetch APIM service (HTTP ${resp.status}) — ` + @@ -260,7 +281,7 @@ async function resolveApimPrincipal( } catch (error) { logger.warn( `Failed to fetch APIM service — skipping Key Vault pre-flight check. ` + - `(Error: ${(error as Error).message})`, + `(Error: ${errorMessage(error)})`, ); return { principalId: undefined, identityLabel }; } @@ -317,8 +338,7 @@ async function findVaultInSubscription( armBaseUrl: string, subscriptionId: string, vaultName: string, - token: string, - armRequest: ArmRequestFn, + armClient: IArmHttpClient, ): Promise<{ resourceId: string; json: unknown } | undefined> { const filter = encodeURIComponent( `resourceType eq 'Microsoft.KeyVault/vaults' and name eq '${vaultName}'`, @@ -329,7 +349,7 @@ async function findVaultInSubscription( let vaultResourceId: string | undefined; try { - const resp = await armRequest(listUrl, token); + const resp = await armGet(armClient, listUrl); if (resp.status !== 200) { logger.warn( `Failed to search for Key Vault '${vaultName}' (HTTP ${resp.status}) — ` + @@ -342,7 +362,7 @@ async function findVaultInSubscription( } catch (error) { logger.warn( `Failed to search for Key Vault '${vaultName}' — skipping pre-flight check. ` + - `(Error: ${(error as Error).message})`, + `(Error: ${errorMessage(error)})`, ); return undefined; } @@ -361,7 +381,7 @@ async function findVaultInSubscription( `?api-version=${KEYVAULT_API_VERSION}`; try { - const resp = await armRequest(vaultUrl, token); + const resp = await armGet(armClient, vaultUrl); if (resp.status !== 200) { logger.warn( `Failed to read Key Vault '${vaultName}' (HTTP ${resp.status}) — ` + @@ -374,7 +394,7 @@ async function findVaultInSubscription( } catch (error) { logger.warn( `Failed to read Key Vault '${vaultName}' — skipping pre-flight check. ` + - `(Error: ${(error as Error).message})`, + `(Error: ${errorMessage(error)})`, ); return undefined; } @@ -396,6 +416,9 @@ const SECRET_GET_WILDCARDS = [ * Throws `KeyVaultAccessError` when no assignments exist at all, or when * none of the assigned roles grant the secret-get data-action. * + * Returns true when access is confirmed, false when the role assignments + * could not be listed and the check was skipped (a warning has been logged). + * * Strategy (fast-path first): * 1. Check if any assignment matches a well-known role GUID — skip role * definition resolution if so. @@ -409,9 +432,8 @@ async function checkRbacAccess( identityLabel: string, vaultName: string, secretName: string, - token: string, - armRequest: ArmRequestFn, -): Promise { + armClient: IArmHttpClient, +): Promise { const filter = encodeURIComponent(`principalId eq '${principalId}'`); const url = `${armBaseUrl}${vaultResourceId}` + @@ -420,13 +442,13 @@ async function checkRbacAccess( let assignments: Array>; try { - const resp = await armRequest(url, token); + const resp = await armGet(armClient, url); if (resp.status !== 200) { logger.warn( `Failed to list role assignments (HTTP ${resp.status}) — ` + `skipping RBAC pre-flight check for '${vaultName}'.`, ); - return; + return false; } const body = (await resp.json()) as { value?: Array>; @@ -435,9 +457,9 @@ async function checkRbacAccess( } catch (error) { logger.warn( `Failed to list role assignments — skipping RBAC pre-flight check. ` + - `(Error: ${(error as Error).message})`, + `(Error: ${errorMessage(error)})`, ); - return; + return false; } if (assignments.length === 0) { @@ -459,7 +481,7 @@ async function checkRbacAccess( }); if (hasKnownRole) { - return; // confirmed — no need to resolve definitions + return true; // confirmed — no need to resolve definitions } // --- Slow path: resolve each role definition and check dataActions --- @@ -475,9 +497,9 @@ async function checkRbacAccess( if (!roleDefId) continue; const hasAccess = await roleDefinitionGrantsSecretGet( - armBaseUrl, roleDefId, token, armRequest, + armBaseUrl, roleDefId, armClient, ); - if (hasAccess) return; // confirmed via dataActions + if (hasAccess) return true; // confirmed via dataActions } throw new KeyVaultAccessError( @@ -499,8 +521,7 @@ async function checkRbacAccess( async function roleDefinitionGrantsSecretGet( armBaseUrl: string, roleDefinitionId: string, - token: string, - armRequest: ArmRequestFn, + armClient: IArmHttpClient, ): Promise { const url = `${armBaseUrl}${roleDefinitionId}` + @@ -508,7 +529,7 @@ async function roleDefinitionGrantsSecretGet( let json: Record; try { - const resp = await armRequest(url, token); + const resp = await armGet(armClient, url); if (resp.status !== 200) { logger.debug( `Failed to fetch role definition (HTTP ${resp.status}): ${roleDefinitionId}`, diff --git a/tests/unit/clients/apic-client.test.ts b/tests/unit/clients/apic-client.test.ts index d0ae1e02..7d28f2df 100644 --- a/tests/unit/clients/apic-client.test.ts +++ b/tests/unit/clients/apic-client.test.ts @@ -23,7 +23,9 @@ import { logger } from '../../../src/lib/logger.js'; const mockGetToken = vi.hoisted(() => vi.fn()); -vi.mock('@azure/identity', () => ({ +vi.mock('@azure/identity', async (importOriginal) => ({ + // Keep the real getBearerTokenProvider (the SDK token cycler) around the mocked credential. + ...(await importOriginal()), DefaultAzureCredential: class { getToken = mockGetToken; }, @@ -69,9 +71,8 @@ describe('ApicClient.validatePreFlight', () => { } beforeEach(() => { + mockGetToken.mockResolvedValue({ token: 'fake-token', expiresOnTimestamp: Date.now() + 60 * 60 * 1000 }); client = new ApicClient(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - vi.spyOn(client as any, 'getToken').mockResolvedValue('fake-token'); fetchSpy = vi.fn(); vi.stubGlobal('fetch', fetchSpy); }); @@ -108,20 +109,6 @@ describe('ApicClient.validatePreFlight', () => { ); }); - it('should pass when the resource group read is forbidden but the API Center service exists', async () => { - const debugSpy = vi.spyOn(logger, 'debug'); - const warnSpy = vi.spyOn(logger, 'warn'); - stubResponses(() => makeResponse(403, rgForbidden), () => makeResponse(200, {})); - - await expect(client.validatePreFlight(testContext)).resolves.toBeUndefined(); - - expect(fetchSpy).toHaveBeenCalledTimes(2); - expect(debugSpy).toHaveBeenCalledWith( - expect.stringContaining("No permission to read resource group 'rg-1' (HTTP 403)"), - ); - expect(warnSpy).not.toHaveBeenCalled(); - }); - it('should report a possibly wrong resource group when the resource group read is forbidden and the service is not found', async () => { stubResponses(() => makeResponse(403, rgForbidden), () => makeResponse(404, { error: { code: 'ResourceNotFound' } })); @@ -140,15 +127,6 @@ describe('ApicClient.validatePreFlight', () => { ); }); - it('should report a missing resource group without checking the service', async () => { - stubResponses(() => makeResponse(404, { error: { code: 'ResourceGroupNotFound' } }), () => makeResponse(200, {})); - - await expect(client.validatePreFlight(testContext)).rejects.toThrow( - "Resource group 'rg-1' not found in subscription 'sub-1'. Ensure the resource group exists before publishing.", - ); - expect(fetchSpy).toHaveBeenCalledTimes(1); - }); - it('should report a missing service when the resource group exists', async () => { stubResponses(() => makeResponse(200, {}), () => makeResponse(404, { error: { code: 'ResourceNotFound' } })); @@ -156,25 +134,6 @@ describe('ApicClient.validatePreFlight', () => { "API Center service 'apic-1' not found in resource group 'rg-1'. Ensure the API Center service exists before publishing.", ); }); - - it('should fail with an authentication error when the resource group read returns 401', async () => { - stubResponses(() => makeResponse(401, { error: { code: 'InvalidAuthenticationToken' } }), () => makeResponse(200, {})); - - await expect(client.validatePreFlight(testContext)).rejects.toThrow( - "Authentication failed when checking resource group 'rg-1'.", - ); - expect(fetchSpy).toHaveBeenCalledTimes(1); - }); - - it('should rethrow other resource group errors unchanged', async () => { - stubResponses(() => makeResponse(400, { error: { code: 'InvalidSubscriptionId' } }), () => makeResponse(200, {})); - - const error = await client.validatePreFlight(testContext).catch((e: unknown) => e); - - expect(error).toBeInstanceOf(ApicHttpError); - expect((error as ApicHttpError).status).toBe(400); - expect(fetchSpy).toHaveBeenCalledTimes(1); - }); }); /* ------------------------------------------------------------------ */ @@ -290,16 +249,8 @@ describe('ApicClient ARM calls', () => { /* Authentication and common headers */ /* ------------------------------------------------------------------ */ - describe('authentication', () => { - it('requests a token for the public ARM scope by default', async () => { - fetchSpy.mockResolvedValueOnce(json(200, {})); - - await settle(client.getResource(testContext, apiDescriptor)); - - expect(mockGetToken).toHaveBeenCalledWith('https://management.azure.com/.default'); - }); - - it('requests a token for the scope passed to the constructor', async () => { + describe('ApicClient authentication', () => { + it('should request a token for the scope passed to the constructor', async () => { const scoped = new ApicClient('https://management.usgovcloudapi.net/.default'); fetchSpy.mockResolvedValueOnce(json(200, {})); @@ -307,68 +258,14 @@ describe('ApicClient ARM calls', () => { expect(mockGetToken).toHaveBeenCalledWith('https://management.usgovcloudapi.net/.default'); }); - - it('sends Authorization, Content-Type and User-Agent on ARM requests', async () => { - fetchSpy.mockResolvedValueOnce(json(200, {})); - - await settle(client.getResource(testContext, apiDescriptor)); - - const { headers } = call(0); - expect(headers.get('Authorization')).toBe('Bearer fake-token'); - expect(headers.get('Content-Type')).toBe('application/json'); - expect(headers.get('User-Agent')).toBe(USER_AGENT); - }); - - it('caches the token across requests', async () => { - fetchSpy.mockImplementation(async () => json(200, {})); - - await settle(client.getResource(testContext, apiDescriptor)); - await settle(client.getResource(testContext, apiDescriptor)); - await settle(client.putResource(testContext, apiDescriptor, {})); - - expect(mockGetToken).toHaveBeenCalledTimes(1); - expect(fetchSpy).toHaveBeenCalledTimes(3); - }); - - it('shares one in-flight token request between concurrent requests', async () => { - fetchSpy.mockImplementation(async () => json(200, {})); - - await settle( - Promise.all([ - client.getResource(testContext, apiDescriptor), - client.getResource(testContext, definitionDescriptor), - ]), - ); - - expect(mockGetToken).toHaveBeenCalledTimes(1); - }); - - it('refreshes the token once it is within five minutes of expiry', async () => { - const start = Date.now(); - mockGetToken - .mockResolvedValueOnce({ token: 'token-1', expiresOnTimestamp: start + 10 * 60 * 1000 }) - .mockResolvedValueOnce({ token: 'token-2', expiresOnTimestamp: start + 70 * 60 * 1000 }); - fetchSpy.mockImplementation(async () => json(200, {})); - - await settle(client.getResource(testContext, apiDescriptor)); - vi.setSystemTime(start + 4 * 60 * 1000); - await settle(client.getResource(testContext, apiDescriptor)); - vi.setSystemTime(start + 6 * 60 * 1000); - await settle(client.getResource(testContext, apiDescriptor)); - - expect(mockGetToken).toHaveBeenCalledTimes(2); - expect(call(0).headers.get('Authorization')).toBe('Bearer token-1'); - expect(call(1).headers.get('Authorization')).toBe('Bearer token-1'); - expect(call(2).headers.get('Authorization')).toBe('Bearer token-2'); - }); }); /* ------------------------------------------------------------------ */ /* listResources */ /* ------------------------------------------------------------------ */ - describe('listResources', () => { - it('lists a service-scoped collection', async () => { + describe('ApicClient.listResources', () => { + it('should list a service-scoped collection', async () => { fetchSpy.mockResolvedValueOnce(json(200, { value: [{ name: 'schema-a' }] })); const items = await settle( @@ -381,7 +278,7 @@ describe('ApicClient ARM calls', () => { expect(call(0).headers.get('Authorization')).toBe('Bearer fake-token'); }); - it('lists a workspace-scoped collection under the encoded workspace', async () => { + it('should list a workspace-scoped collection under the encoded workspace', async () => { fetchSpy.mockResolvedValueOnce(json(200, { value: [] })); await settle( @@ -393,7 +290,7 @@ describe('ApicClient ARM calls', () => { ); }); - it('lists a child collection with encoded parent name-parts', async () => { + it('should list a child collection with encoded parent name-parts', async () => { fetchSpy.mockResolvedValueOnce(json(200, { value: [{ name: 'openapi' }] })); await settle( @@ -413,7 +310,7 @@ describe('ApicClient ARM calls', () => { ); }); - it('follows nextLink until it is absent', async () => { + it('should follow nextLink until it is absent', async () => { const page2 = `${BASE_URL}/workspaces/default/apis?api-version=${API_VERSION}&$skipToken=2`; const page3 = `${BASE_URL}/workspaces/default/apis?api-version=${API_VERSION}&$skipToken=3`; fetchSpy @@ -434,7 +331,7 @@ describe('ApicClient ARM calls', () => { expect(call(1).headers.get('Authorization')).toBe('Bearer fake-token'); }); - it('returns an empty list when the collection returns 404', async () => { + it('should return an empty list when the collection returns 404', async () => { fetchSpy.mockResolvedValueOnce( json(404, { error: { code: 'ResourceNotFound', message: 'missing' } }), ); @@ -447,7 +344,7 @@ describe('ApicClient ARM calls', () => { expect(fetchSpy).toHaveBeenCalledTimes(1); }); - it('tolerates a page without a value array', async () => { + it('should tolerate a page without a value array', async () => { fetchSpy.mockResolvedValueOnce(json(200, {})); const items = await settle( @@ -457,7 +354,7 @@ describe('ApicClient ARM calls', () => { expect(items).toEqual([]); }); - it('throws ApicHttpError with status and ARM code on 403', async () => { + it('should throw ApicHttpError with status and ARM code on 403', async () => { fetchSpy.mockResolvedValueOnce( json(403, { error: { code: 'AuthorizationFailed', message: 'denied' } }), ); @@ -476,8 +373,8 @@ describe('ApicClient ARM calls', () => { /* getResource */ /* ------------------------------------------------------------------ */ - describe('getResource', () => { - it('GETs the encoded resource URL and returns the parsed body', async () => { + describe('ApicClient.getResource', () => { + it('should GET the encoded resource URL and return the parsed body', async () => { fetchSpy.mockResolvedValueOnce(json(200, { name: 'pet store', properties: { title: 'Pets' } })); const result = await settle(client.getResource(testContext, apiDescriptor)); @@ -488,7 +385,7 @@ describe('ApicClient ARM calls', () => { expect(call(0).body).toBeUndefined(); }); - it('builds a service-scoped resource URL without a workspace segment', async () => { + it('should build a service-scoped resource URL without a workspace segment', async () => { fetchSpy.mockResolvedValueOnce(json(200, {})); await settle( @@ -498,14 +395,22 @@ describe('ApicClient ARM calls', () => { expect(call(0).url).toBe(`${BASE_URL}/metadataSchemas/schema%20a?api-version=${API_VERSION}`); }); - it('returns undefined on 404', async () => { + it('should return undefined on 404', async () => { fetchSpy.mockResolvedValueOnce(json(404, { error: { code: 'ResourceNotFound' } })); await expect(settle(client.getResource(testContext, apiDescriptor))).resolves.toBeUndefined(); expect(fetchSpy).toHaveBeenCalledTimes(1); }); - it('returns an empty object for an empty 200 body', async () => { + it('should cancel the unread body of a 404', async () => { + const cancel = vi.fn(); + fetchSpy.mockResolvedValueOnce(new Response(new ReadableStream({ cancel }), { status: 404 })); + + await expect(settle(client.getResource(testContext, apiDescriptor))).resolves.toBeUndefined(); + expect(cancel).toHaveBeenCalledTimes(1); + }); + + it('should return an empty object for an empty 200 body', async () => { fetchSpy.mockResolvedValueOnce(empty(200)); await expect(settle(client.getResource(testContext, apiDescriptor))).resolves.toEqual({}); @@ -516,10 +421,24 @@ describe('ApicClient ARM calls', () => { /* putResource */ /* ------------------------------------------------------------------ */ - describe('putResource', () => { + describe('ApicClient.putResource', () => { const payload = { properties: { title: 'Pets', kind: 'rest' } }; - it.each([200, 201])('PUTs the JSON payload and returns the %i body', async (status) => { + it('should cancel the unread PUT body when it polls an async operation', async () => { + const cancel = vi.fn(); + fetchSpy + .mockResolvedValueOnce(new Response(new ReadableStream({ cancel }), { + status: 201, + headers: { 'Azure-AsyncOperation': OPERATION_URL }, + })) + .mockResolvedValueOnce(json(200, { status: 'Succeeded' })) + .mockResolvedValueOnce(json(200, { name: 'pet store' })); + + await expect(settle(client.putResource(testContext, apiDescriptor, payload))).resolves.toEqual({ name: 'pet store' }); + expect(cancel).toHaveBeenCalledTimes(1); + }); + + it.each([200, 201])('should PUT the JSON payload and return the %i body', async (status) => { fetchSpy.mockResolvedValueOnce(json(status, { name: 'pet store', ...payload })); const result = await settle(client.putResource(testContext, apiDescriptor, payload)); @@ -535,13 +454,13 @@ describe('ApicClient ARM calls', () => { expect(put.headers.get('User-Agent')).toBe(USER_AGENT); }); - it('returns an empty object for an empty 200 body', async () => { + it('should return an empty object for an empty 200 body', async () => { fetchSpy.mockResolvedValueOnce(empty(200)); await expect(settle(client.putResource(testContext, apiDescriptor, payload))).resolves.toEqual({}); }); - it('polls Azure-AsyncOperation on 201 and returns the settled resource', async () => { + it('should poll Azure-AsyncOperation on 201 and return the settled resource', async () => { fetchSpy .mockResolvedValueOnce(json(201, { name: 'pet store' }, { 'Azure-AsyncOperation': OPERATION_URL })) .mockResolvedValueOnce(json(200, { status: 'InProgress' })) @@ -557,7 +476,17 @@ describe('ApicClient ARM calls', () => { expect(call(3).method).toBe('GET'); }); - it('polls the Location header on 202 and reads provisioningState from the poll body', async () => { + it('should return an empty object when the resource is gone once the operation completes', async () => { + fetchSpy + .mockResolvedValueOnce(json(201, { name: 'pet store' }, { 'Azure-AsyncOperation': OPERATION_URL })) + .mockResolvedValueOnce(json(200, { status: 'Succeeded' })) + .mockResolvedValueOnce(json(404, { error: { code: 'ResourceNotFound' } })); + + await expect(settle(client.putResource(testContext, apiDescriptor, payload))).resolves.toEqual({}); + expect(urls()).toEqual([API_URL, OPERATION_URL, API_URL]); + }); + + it('should poll the Location header on 202 and read provisioningState from the poll body', async () => { const location = `${API_URL}&operation=1`; fetchSpy .mockResolvedValueOnce(empty(202, { Location: location })) @@ -571,7 +500,7 @@ describe('ApicClient ARM calls', () => { expect(urls()).toEqual([API_URL, location, location, API_URL]); }); - it('throws ApicHttpError without retrying a 400', async () => { + it('should throw ApicHttpError without retrying a 400', async () => { fetchSpy.mockResolvedValueOnce( json(400, { error: { code: 'ValidationError', message: 'bad title' } }), ); @@ -586,7 +515,7 @@ describe('ApicClient ARM calls', () => { expect(fetchSpy).toHaveBeenCalledTimes(1); }); - it('throws ApicHttpError without a code for a non-JSON error body', async () => { + it('should throw ApicHttpError without a code for a non-JSON error body', async () => { fetchSpy.mockResolvedValueOnce(new Response('Conflict', { status: 409 })); const error = await settle(client.putResource(testContext, apiDescriptor, payload)).catch( @@ -602,8 +531,18 @@ describe('ApicClient ARM calls', () => { /* deleteResource */ /* ------------------------------------------------------------------ */ - describe('deleteResource', () => { - it.each([200, 204])('DELETEs the resource URL and returns true on %i', async (status) => { + describe('ApicClient.deleteResource', () => { + it.each([200, 202])('should cancel the unread body of a %i DELETE response', async (status) => { + const cancel = vi.fn(); + fetchSpy + .mockResolvedValueOnce(new Response(new ReadableStream({ cancel }), { status })) + .mockResolvedValueOnce(json(404, { error: { code: 'ResourceNotFound' } })); + + await expect(settle(client.deleteResource(testContext, apiDescriptor))).resolves.toBe(true); + expect(cancel).toHaveBeenCalledTimes(1); + }); + + it.each([200, 204])('should DELETE the resource URL and return true on %i', async (status) => { fetchSpy.mockResolvedValueOnce(empty(status)); await expect(settle(client.deleteResource(testContext, apiDescriptor))).resolves.toBe(true); @@ -614,14 +553,22 @@ describe('ApicClient ARM calls', () => { expect(call(0).headers.get('Authorization')).toBe('Bearer fake-token'); }); - it('returns false on 404', async () => { + it('should return false on 404', async () => { fetchSpy.mockResolvedValueOnce(json(404, { error: { code: 'ResourceNotFound' } })); await expect(settle(client.deleteResource(testContext, apiDescriptor))).resolves.toBe(false); expect(fetchSpy).toHaveBeenCalledTimes(1); }); - it('polls Azure-AsyncOperation on 202 until Succeeded', async () => { + it('should cancel the unread body of a 404', async () => { + const cancel = vi.fn(); + fetchSpy.mockResolvedValueOnce(new Response(new ReadableStream({ cancel }), { status: 404 })); + + await expect(settle(client.deleteResource(testContext, apiDescriptor))).resolves.toBe(false); + expect(cancel).toHaveBeenCalledTimes(1); + }); + + it('should poll Azure-AsyncOperation on 202 until Succeeded', async () => { fetchSpy .mockResolvedValueOnce(empty(202, { 'Azure-AsyncOperation': OPERATION_URL })) .mockResolvedValueOnce(json(200, { status: 'InProgress' })) @@ -632,7 +579,7 @@ describe('ApicClient ARM calls', () => { expect(urls()).toEqual([API_URL, OPERATION_URL, OPERATION_URL]); }); - it('treats a 404 while polling a delete as success', async () => { + it('should treat a 404 while polling a delete as success', async () => { fetchSpy .mockResolvedValueOnce(empty(202, { Location: OPERATION_URL })) .mockResolvedValueOnce(empty(404)); @@ -646,14 +593,14 @@ describe('ApicClient ARM calls', () => { /* exportSpecification */ /* ------------------------------------------------------------------ */ - describe('exportSpecification', () => { + describe('ApicClient.exportSpecification', () => { const EXPORT_URL = `${DEFINITION_PATH}/exportSpecification?api-version=${API_VERSION}`; const definition = { name: 'openapi', properties: { specification: { name: 'openapi', version: '3.0.1' } }, }; - it('POSTs the export action and returns inline content with the spec name/version', async () => { + it('should POST the export action and return inline content with the spec name/version', async () => { fetchSpy .mockResolvedValueOnce(json(200, { format: 'inline', value: 'openapi: 3.0.1' })) .mockResolvedValueOnce(json(200, definition)); @@ -670,7 +617,7 @@ describe('ApicClient ARM calls', () => { expect(post.headers.get('User-Agent')).toBe(USER_AGENT); }); - it('fetches a link export without ARM credentials', async () => { + it('should fetch a link export without ARM credentials', async () => { const link = 'https://blob.example.net/specs/petstore.yaml?sv=2024&sig=secret'; fetchSpy .mockResolvedValueOnce(json(200, { format: 'link', value: link })) @@ -688,7 +635,7 @@ describe('ApicClient ARM calls', () => { expect(blob.headers.get('User-Agent')).toBe(USER_AGENT); }); - it('retries a link export that fails with a transient 5xx', async () => { + it('should retry a link export that fails with a transient 5xx', async () => { const link = 'https://blob.example.net/specs/petstore.yaml?sig=secret'; fetchSpy .mockResolvedValueOnce(json(200, { format: 'link', value: link })) @@ -702,7 +649,7 @@ describe('ApicClient ARM calls', () => { expect(urls()).toEqual([EXPORT_URL, link, link, DEFINITION_URL]); }); - it('returns undefined when the link export returns 404', async () => { + it('should return undefined when the link export returns 404', async () => { const link = 'https://blob.example.net/specs/missing.yaml?sig=secret'; fetchSpy .mockResolvedValueOnce(json(200, { format: 'link', value: link })) @@ -714,7 +661,35 @@ describe('ApicClient ARM calls', () => { expect(fetchSpy).toHaveBeenCalledTimes(2); }); - it('defaults the spec name to openapi when the definition has none', async () => { + it.each([ + [403, 1], + [500, 4], + ])('should fail the export when the link fetch fails with %i', async (status, linkCalls) => { + const link = 'https://blob.example.net/specs/petstore.yaml?sig=secret'; + fetchSpy + .mockResolvedValueOnce(json(200, { format: 'link', value: link })) + .mockImplementation(async () => new Response('AuthenticationFailed', { status })); + + const error = await settle(client.exportSpecification(testContext, definitionDescriptor)).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(ApicHttpError); + expect((error as ApicHttpError).status).toBe(status); + expect(fetchSpy).toHaveBeenCalledTimes(1 + linkCalls); + }); + + it('should fail the export when the link fetch keeps failing with a network error', async () => { + const link = 'https://blob.example.net/specs/petstore.yaml?sig=secret'; + fetchSpy + .mockResolvedValueOnce(json(200, { format: 'link', value: link })) + .mockRejectedValue(new TypeError('fetch failed')); + + await expect( + settle(client.exportSpecification(testContext, definitionDescriptor)), + ).rejects.toThrow('fetch failed'); + expect(fetchSpy).toHaveBeenCalledTimes(1 + 4); + }); + + it('should default the spec name to openapi when the definition has none', async () => { fetchSpy .mockResolvedValueOnce(json(200, { format: 'inline', value: '{}' })) .mockResolvedValueOnce(json(404, { error: { code: 'ResourceNotFound' } })); @@ -724,7 +699,7 @@ describe('ApicClient ARM calls', () => { expect(spec).toEqual({ content: '{}', name: 'openapi', version: undefined }); }); - it('returns undefined when the export action returns 404', async () => { + it('should return undefined when the export action returns 404', async () => { fetchSpy.mockResolvedValueOnce(json(404, { error: { code: 'ResourceNotFound' } })); await expect( @@ -733,7 +708,7 @@ describe('ApicClient ARM calls', () => { expect(fetchSpy).toHaveBeenCalledTimes(1); }); - it('warns and returns undefined when the export action fails', async () => { + it('should warn and return undefined when the export action fails', async () => { fetchSpy.mockResolvedValueOnce(json(400, { error: { code: 'BadRequest' } })); await expect( @@ -747,7 +722,7 @@ describe('ApicClient ARM calls', () => { it.each([ ['an empty body', empty(200)], ['no value', json(200, { format: 'inline' })], - ])('returns undefined for %s', async (_label, response) => { + ])('should return undefined for %s', async (_label, response) => { fetchSpy.mockResolvedValueOnce(response); await expect( @@ -761,10 +736,20 @@ describe('ApicClient ARM calls', () => { /* importSpecification */ /* ------------------------------------------------------------------ */ - describe('importSpecification', () => { + describe('ApicClient.importSpecification', () => { const IMPORT_URL = `${DEFINITION_PATH}/importSpecification?api-version=${API_VERSION}`; - it('POSTs the inline spec with its name and version', async () => { + it('should cancel the unread body of the import response', async () => { + const cancel = vi.fn(); + fetchSpy.mockResolvedValueOnce(new Response(new ReadableStream({ cancel }), { status: 200 })); + + await settle( + client.importSpecification(testContext, definitionDescriptor, { content: 'openapi: 3.0.1', name: 'openapi' }), + ); + expect(cancel).toHaveBeenCalledTimes(1); + }); + + it('should POST the inline spec with its name and version', async () => { fetchSpy.mockResolvedValueOnce(empty(200)); await settle( @@ -788,7 +773,7 @@ describe('ApicClient ARM calls', () => { expect(post.headers.get('Content-Type')).toBe('application/json'); }); - it('omits the version when the spec has none', async () => { + it('should omit the version when the spec has none', async () => { fetchSpy.mockResolvedValueOnce(empty(200)); await settle( @@ -798,7 +783,7 @@ describe('ApicClient ARM calls', () => { expect(JSON.parse(call(0).body as string).specification).toEqual({ name: 'graphql' }); }); - it('polls the operation when the import returns 202', async () => { + it('should poll the operation when the import returns 202', async () => { fetchSpy .mockResolvedValueOnce(empty(202, { 'Azure-AsyncOperation': OPERATION_URL })) .mockResolvedValueOnce(json(200, { status: 'Succeeded' })); @@ -810,7 +795,17 @@ describe('ApicClient ARM calls', () => { expect(urls()).toEqual([IMPORT_URL, OPERATION_URL]); }); - it('surfaces a failed import operation', async () => { + it('should finish without polling when the import returns 202 without a polling header', async () => { + fetchSpy.mockResolvedValueOnce(empty(202)); + + await settle( + client.importSpecification(testContext, definitionDescriptor, { content: 'x', name: 'openapi' }), + ); + + expect(urls()).toEqual([IMPORT_URL]); + }); + + it('should surface a failed import operation', async () => { fetchSpy .mockResolvedValueOnce(empty(202, { 'Azure-AsyncOperation': OPERATION_URL })) .mockResolvedValueOnce( @@ -829,8 +824,8 @@ describe('ApicClient ARM calls', () => { /* Retry and rate limiting */ /* ------------------------------------------------------------------ */ - describe('retries', () => { - it('retries a 5xx and then succeeds', async () => { + describe('ApicClient request retries', () => { + it('should retry a 5xx and then succeed', async () => { fetchSpy .mockResolvedValueOnce(new Response('busy', { status: 503 })) .mockResolvedValueOnce(new Response('oops', { status: 500 })) @@ -842,109 +837,29 @@ describe('ApicClient ARM calls', () => { expect(urls()).toEqual([API_URL, API_URL, API_URL]); }); - it('gives up after three retries and throws the last 5xx as ApicHttpError', async () => { - fetchSpy.mockImplementation(async () => new Response('down', { status: 502 })); - - const error = await settle(client.getResource(testContext, apiDescriptor)).catch( - (e: unknown) => e, - ); - - expect(error).toBeInstanceOf(ApicHttpError); - expect(error).toMatchObject({ status: 502 }); - expect(fetchSpy).toHaveBeenCalledTimes(4); - }); - - it('retries a network error and then succeeds', async () => { - fetchSpy - .mockRejectedValueOnce(new TypeError('fetch failed')) - .mockResolvedValueOnce(json(200, { name: 'pet store' })); - - await expect(settle(client.getResource(testContext, apiDescriptor))).resolves.toEqual({ - name: 'pet store', - }); - expect(fetchSpy).toHaveBeenCalledTimes(2); - }); - - it.each([400, 401, 403, 409])('does not retry a %i', async (status) => { - fetchSpy.mockResolvedValueOnce(json(status, { error: { code: 'Nope' } })); + it('should not retry a 400', async () => { + fetchSpy.mockResolvedValueOnce(json(400, { error: { code: 'Nope' } })); await expect( settle(client.putResource(testContext, apiDescriptor, {})), - ).rejects.toMatchObject({ status, code: 'Nope' }); + ).rejects.toMatchObject({ status: 400, code: 'Nope' }); expect(fetchSpy).toHaveBeenCalledTimes(1); }); - - it('waits for a numeric Retry-After after a 429 and then succeeds', async () => { - fetchSpy - .mockResolvedValueOnce(empty(429, { 'Retry-After': '2' })) - .mockResolvedValueOnce(json(200, { name: 'pet store' })); - - const result = client.getResource(testContext, apiDescriptor); - await vi.advanceTimersByTimeAsync(1999); - expect(fetchSpy).toHaveBeenCalledTimes(1); - await vi.advanceTimersByTimeAsync(1); - expect(fetchSpy).toHaveBeenCalledTimes(2); - - await expect(result).resolves.toEqual({ name: 'pet store' }); - }); - - it('backs off after a 429 without Retry-After and then succeeds', async () => { - fetchSpy - .mockResolvedValueOnce(empty(429)) - .mockResolvedValueOnce(json(200, { name: 'pet store' })); - - await expect(settle(client.getResource(testContext, apiDescriptor))).resolves.toEqual({ - name: 'pet store', - }); - expect(fetchSpy).toHaveBeenCalledTimes(2); - }); }); /* ------------------------------------------------------------------ */ /* Long-running operation polling */ /* ------------------------------------------------------------------ */ - describe('long-running operation polling', () => { + describe('ApicClient long-running operation polling', () => { function acceptedPut(): void { fetchSpy.mockResolvedValueOnce(empty(202, { 'Azure-AsyncOperation': OPERATION_URL })); } - it('keeps polling while the operation is in progress', async () => { - acceptedPut(); - fetchSpy - .mockResolvedValueOnce(json(200, { status: 'Accepted' })) - .mockResolvedValueOnce(json(200, { status: 'InProgress' })) - .mockResolvedValueOnce(json(200, { status: 'Succeeded' })) - .mockResolvedValueOnce(json(200, { name: 'pet store' })); - - await expect( - settle(client.putResource(testContext, apiDescriptor, {})), - ).resolves.toEqual({ name: 'pet store' }); - expect(urls()).toEqual([API_URL, OPERATION_URL, OPERATION_URL, OPERATION_URL, API_URL]); - }); - - it('waits for a numeric Retry-After between polls', async () => { - acceptedPut(); - fetchSpy - .mockResolvedValueOnce(json(200, { status: 'InProgress' }, { 'Retry-After': '7' })) - .mockResolvedValueOnce(json(200, { status: 'Succeeded' })) - .mockResolvedValueOnce(json(200, { name: 'pet store' })); - - const result = client.putResource(testContext, apiDescriptor, {}); - await vi.advanceTimersByTimeAsync(0); - const pollsBefore = fetchSpy.mock.calls.length; - await vi.advanceTimersByTimeAsync(6999); - expect(fetchSpy).toHaveBeenCalledTimes(pollsBefore); - await vi.advanceTimersByTimeAsync(1); - await settle(result); - - expect(fetchSpy).toHaveBeenCalledTimes(4); - }); - - it.each(['Failed', 'Canceled'])('throws with the label and error message when %s', async (status) => { + it('should throw with the label and error message when the operation fails', async () => { acceptedPut(); fetchSpy.mockResolvedValueOnce( - json(200, { status, error: { code: 'Boom', message: 'Spec is invalid' } }), + json(200, { status: 'Failed', error: { code: 'Boom', message: 'Spec is invalid' } }), ); const error = (await settle(client.putResource(testContext, apiDescriptor, {})).catch( @@ -952,33 +867,13 @@ describe('ApicClient ARM calls', () => { )) as Error; expect(error).toBeInstanceOf(Error); - expect(error.message).toMatch(new RegExp(status, 'i')); + expect(error.message).toMatch(/failed/i); expect(error.message).toContain('workspaces/default/apis/pet store'); expect(error.message).toContain('Spec is invalid'); expect(fetchSpy).toHaveBeenCalledTimes(2); }); - it('throws when the operation disappears (404) while polling a PUT', async () => { - acceptedPut(); - fetchSpy.mockResolvedValueOnce(empty(404)); - - await expect(settle(client.putResource(testContext, apiDescriptor, {}))).rejects.toThrow( - /workspaces\/default\/apis\/pet store.*(not found|404)/, - ); - }); - - it('treats an empty 200 poll response as success', async () => { - acceptedPut(); - fetchSpy - .mockResolvedValueOnce(empty(200)) - .mockResolvedValueOnce(json(200, { name: 'pet store' })); - - await expect( - settle(client.putResource(testContext, apiDescriptor, {})), - ).resolves.toEqual({ name: 'pet store' }); - }); - - it('times out after ten minutes of polling', async () => { + it('should time out after ten minutes of polling', async () => { acceptedPut(); fetchSpy.mockImplementation(async () => json(200, { status: 'InProgress' })); const start = Date.now(); @@ -989,65 +884,7 @@ describe('ApicClient ARM calls', () => { expect(Date.now() - start).toBeGreaterThanOrEqual(10 * 60 * 1000); }); - it.each([ - ['succeeded', true], - ['SUCCEEDED', true], - ['failed', false], - ['canceled', false], - ['Cancelled', false], - ])('matches the terminal status %s case-insensitively', async (status, succeeds) => { - acceptedPut(); - fetchSpy - .mockResolvedValueOnce(json(200, { status, error: { message: 'Spec is invalid' } })) - .mockResolvedValueOnce(json(200, { name: 'pet store' })); - - const outcome = settle(client.putResource(testContext, apiDescriptor, {})); - - if (succeeds) { - await expect(outcome).resolves.toEqual({ name: 'pet store' }); - expect(fetchSpy).toHaveBeenCalledTimes(3); - } else { - await expect(outcome).rejects.toThrow(new RegExp(`${status}.*Spec is invalid`, 'i')); - expect(fetchSpy).toHaveBeenCalledTimes(2); - } - }); - - it('waits until an HTTP-date Retry-After between polls', async () => { - vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); - acceptedPut(); - fetchSpy - .mockResolvedValueOnce( - json(200, { status: 'InProgress' }, { 'Retry-After': 'Thu, 01 Jan 2026 00:00:08 GMT' }), - ) - .mockResolvedValueOnce(json(200, { status: 'Succeeded' })) - .mockResolvedValueOnce(json(200, { name: 'pet store' })); - - const result = client.putResource(testContext, apiDescriptor, {}); - await vi.advanceTimersByTimeAsync(7999); - expect(fetchSpy).toHaveBeenCalledTimes(2); - await vi.advanceTimersByTimeAsync(1); - await settle(result); - - expect(fetchSpy).toHaveBeenCalledTimes(4); - }); - - it('falls back to the default interval for an unparsable Retry-After', async () => { - acceptedPut(); - fetchSpy - .mockResolvedValueOnce(json(200, { status: 'InProgress' }, { 'Retry-After': 'soon' })) - .mockResolvedValueOnce(json(200, { status: 'Succeeded' })) - .mockResolvedValueOnce(json(200, { name: 'pet store' })); - - const result = client.putResource(testContext, apiDescriptor, {}); - await vi.advanceTimersByTimeAsync(4999); - expect(fetchSpy).toHaveBeenCalledTimes(2); - await vi.advanceTimersByTimeAsync(1); - await settle(result); - - expect(fetchSpy).toHaveBeenCalledTimes(4); - }); - - it('polls Azure-AsyncOperation when a PUT update returns 200', async () => { + it('should poll Azure-AsyncOperation when a PUT update returns 200', async () => { fetchSpy .mockResolvedValueOnce( json(200, { properties: { provisioningState: 'Updating' } }, { 'Azure-AsyncOperation': OPERATION_URL }), @@ -1061,7 +898,7 @@ describe('ApicClient ARM calls', () => { expect(urls()).toEqual([API_URL, OPERATION_URL, API_URL]); }); - it('does not poll a Location header on a PUT that returns 200', async () => { + it('should not poll a Location header on a PUT that returns 200', async () => { fetchSpy.mockResolvedValueOnce(json(200, { name: 'pet store' }, { Location: OPERATION_URL })); await expect( @@ -1070,7 +907,7 @@ describe('ApicClient ARM calls', () => { expect(fetchSpy).toHaveBeenCalledTimes(1); }); - it('polls the resource when a DELETE returns 202 without a polling header', async () => { + it('should poll the resource when a DELETE returns 202 without a polling header', async () => { fetchSpy .mockResolvedValueOnce(empty(202)) .mockResolvedValueOnce(json(200, { name: 'pet store', properties: { provisioningState: 'Deleting' } })) @@ -1084,74 +921,45 @@ describe('ApicClient ARM calls', () => { expect(call(2).method).toBe('GET'); }); - it('fails a DELETE without a polling header when the resource reports Failed', async () => { + it('should release the unread body of each resource GET while waiting for a delete', async () => { + const cancel = vi.fn(); fetchSpy .mockResolvedValueOnce(empty(202)) - .mockResolvedValueOnce(json(200, { properties: { provisioningState: 'Failed' } })); - - await expect(settle(client.deleteResource(testContext, apiDescriptor))).rejects.toThrow( - /workspaces\/default\/apis\/pet store.*failed/i, - ); - }); - }); + .mockResolvedValueOnce(new Response(new ReadableStream({ cancel }), { status: 200 })) + .mockResolvedValueOnce(new Response(new ReadableStream({ cancel }), { status: 404 })); - /* ------------------------------------------------------------------ */ - /* Rate limiting hardening */ - /* ------------------------------------------------------------------ */ - - describe('rate limiting', () => { - it('throws ApicHttpError 429 once the retries are exhausted', async () => { - fetchSpy.mockImplementation(async () => - json(429, { error: { code: 'TooManyRequests', message: 'slow down' } }, { 'Retry-After': '1' }), - ); - - const error = await settle(client.getResource(testContext, apiDescriptor)).catch( - (e: unknown) => e, - ); - - expect(error).toBeInstanceOf(ApicHttpError); - expect(error).toMatchObject({ status: 429, code: 'TooManyRequests' }); - expect(fetchSpy).toHaveBeenCalledTimes(4); - }); - - it('does not wait again after the final 429', async () => { - fetchSpy.mockImplementation(async () => empty(429, { 'Retry-After': '10' })); - - const result = client.getResource(testContext, apiDescriptor).catch((e: unknown) => e); - // Three retries wait 10s each; the fourth 429 must fail without a fourth wait. - await vi.advanceTimersByTimeAsync(30_000); + await expect(settle(client.deleteResource(testContext, apiDescriptor))).resolves.toBe(true); - expect(fetchSpy).toHaveBeenCalledTimes(4); - await expect(result).resolves.toMatchObject({ status: 429 }); + expect(cancel).toHaveBeenCalledTimes(2); }); - it('waits until an HTTP-date Retry-After after a 429', async () => { - vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + it('should time out a DELETE without a polling header when the resource never disappears', async () => { fetchSpy - .mockResolvedValueOnce(empty(429, { 'Retry-After': 'Thu, 01 Jan 2026 00:00:03 GMT' })) - .mockResolvedValueOnce(json(200, { name: 'pet store' })); - - const result = client.getResource(testContext, apiDescriptor); - await vi.advanceTimersByTimeAsync(2999); - expect(fetchSpy).toHaveBeenCalledTimes(1); - await vi.advanceTimersByTimeAsync(1); - expect(fetchSpy).toHaveBeenCalledTimes(2); + .mockResolvedValueOnce(empty(202)) + .mockImplementation(async () => json(200, { name: 'pet store' })); + const start = Date.now(); - await expect(result).resolves.toEqual({ name: 'pet store' }); + await expect(settle(client.deleteResource(testContext, apiDescriptor))).rejects.toThrow( + 'workspaces/default/apis/pet store was not deleted within 10m 0.0s', + ); + expect(Date.now() - start).toBeGreaterThanOrEqual(10 * 60 * 1000); }); - it('backs off exponentially for an unparsable Retry-After after a 429', async () => { - fetchSpy - .mockResolvedValueOnce(empty(429, { 'Retry-After': 'later' })) - .mockResolvedValueOnce(json(200, { name: 'pet store' })); - - const result = client.getResource(testContext, apiDescriptor); - await vi.advanceTimersByTimeAsync(999); - expect(fetchSpy).toHaveBeenCalledTimes(1); - await vi.advanceTimersByTimeAsync(1); - expect(fetchSpy).toHaveBeenCalledTimes(2); + it('should wait 5 seconds before each resource GET', async () => { + const delays: number[] = []; + const recording = new ApicClient(undefined, { + delay: async (ms) => { + delays.push(ms); + }, + }); + fetchSpy.mockResolvedValueOnce(empty(202)); + for (let i = 0; i < 3; i++) { + fetchSpy.mockResolvedValueOnce(json(200, { name: 'pet store' })); + } + fetchSpy.mockResolvedValueOnce(empty(404)); - await expect(result).resolves.toEqual({ name: 'pet store' }); + await expect(recording.deleteResource(testContext, apiDescriptor)).resolves.toBe(true); + expect(delays).toEqual([5000, 5000, 5000, 5000]); }); }); @@ -1159,14 +967,10 @@ describe('ApicClient ARM calls', () => { /* ARM host validation */ /* ------------------------------------------------------------------ */ - describe('ARM host validation', () => { - it.each([ - ['a non-HTTPS URL', 'http://management.azure.com/operations/op-1'], - ['an unknown host', 'https://attacker.example.com/operations/op-1'], - ['a look-alike host', 'https://management.azure.com.attacker.example.com/operations/op-1'], - ])('does not poll (or send the token to) %s from a PUT', async (_label, operationUrl) => { + describe('ApicClient ARM host validation', () => { + it('should not poll (or send the token to) an unknown host from a PUT', async () => { fetchSpy.mockResolvedValueOnce( - json(201, { name: 'pet store' }, { 'Azure-AsyncOperation': operationUrl }), + json(201, { name: 'pet store' }, { 'Azure-AsyncOperation': 'https://attacker.example.com/operations/op-1' }), ); await expect( @@ -1176,7 +980,7 @@ describe('ApicClient ARM calls', () => { expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Ignoring')); }); - it('does not poll an unknown host from a DELETE and polls the resource instead', async () => { + it('should not poll an unknown host from a DELETE and poll the resource instead', async () => { fetchSpy .mockResolvedValueOnce(empty(202, { Location: 'https://attacker.example.com/op' })) .mockResolvedValueOnce(empty(404)); @@ -1186,9 +990,9 @@ describe('ApicClient ARM calls', () => { }); it.each([ - 'https://management.usgovcloudapi.net/operations/op-1', + 'https://management.azure.com/operations/op-1', 'https://eastus.management.azure.com/operations/op-1', - ])('polls an operation on the known ARM host %s', async (operationUrl) => { + ])('should poll an operation on its own cloud\'s ARM host or a subdomain of one: %s', async (operationUrl) => { fetchSpy .mockResolvedValueOnce(empty(202, { 'Azure-AsyncOperation': operationUrl })) .mockResolvedValueOnce(json(200, { status: 'Succeeded' })); @@ -1197,31 +1001,43 @@ describe('ApicClient ARM calls', () => { expect(urls()).toEqual([API_URL, operationUrl]); }); - it('polls Operation-Location', async () => { + it('should not poll another cloud\'s ARM host from a public-cloud client', async () => { + const operationUrl = 'https://management.usgovcloudapi.net/operations/op-1'; fetchSpy - .mockResolvedValueOnce(empty(202, { 'Operation-Location': OPERATION_URL })) - .mockResolvedValueOnce(json(200, { status: 'Succeeded' })); + .mockResolvedValueOnce(empty(202, { 'Azure-AsyncOperation': operationUrl })) + .mockResolvedValueOnce(empty(404)); await expect(settle(client.deleteResource(testContext, apiDescriptor))).resolves.toBe(true); - expect(urls()).toEqual([API_URL, OPERATION_URL]); + expect(urls()).toEqual([API_URL, API_URL]); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Ignoring')); }); - it('prefers Azure-AsyncOperation over Location', async () => { + it('should poll the US Government ARM host from a US Government client but not the public one', async () => { + const usgovClient = new ApicClient('https://management.usgovcloudapi.net/.default'); + const usgovContext: ApicServiceContext = { + ...testContext, + baseUrl: buildApicBaseUrl('usgov', 'sub-1', 'rg-1', 'apic-1'), + }; + const usgovOperation = 'https://management.usgovcloudapi.net/operations/op-1'; fetchSpy - .mockResolvedValueOnce( - empty(202, { 'Azure-AsyncOperation': OPERATION_URL, Location: `${API_URL}&location=1` }), - ) - .mockResolvedValueOnce(json(200, { status: 'Succeeded' })); + .mockResolvedValueOnce(empty(202, { 'Azure-AsyncOperation': usgovOperation })) + .mockResolvedValueOnce(json(200, { status: 'Succeeded' })) + .mockResolvedValueOnce(json(201, { name: 'pet store' }, { 'Azure-AsyncOperation': OPERATION_URL })); - await expect(settle(client.deleteResource(testContext, apiDescriptor))).resolves.toBe(true); - expect(urls()).toEqual([API_URL, OPERATION_URL]); + await expect(settle(usgovClient.deleteResource(usgovContext, apiDescriptor))).resolves.toBe(true); + await expect( + settle(usgovClient.putResource(usgovContext, apiDescriptor, {})), + ).resolves.toEqual({ name: 'pet store' }); + + expect(urls()[1]).toBe(usgovOperation); + expect(urls()).toHaveLength(3); + expect(urls()).not.toContain(OPERATION_URL); }); - it.each([ - ['an unknown host', 'https://attacker.example.com/apis?page=2'], - ['a non-HTTPS URL', 'http://management.azure.com/apis?page=2'], - ])('refuses to follow a nextLink to %s', async (_label, nextLink) => { - fetchSpy.mockResolvedValueOnce(json(200, { value: [{ name: 'a' }], nextLink })); + it('should refuse to follow a nextLink to an unknown host', async () => { + fetchSpy.mockResolvedValueOnce( + json(200, { value: [{ name: 'a' }], nextLink: 'https://attacker.example.com/apis?page=2' }), + ); const items: unknown[] = []; const error = await settle( diff --git a/tests/unit/clients/apim-client.test.ts b/tests/unit/clients/apim-client.test.ts index 67b63767..8dc43771 100644 --- a/tests/unit/clients/apim-client.test.ts +++ b/tests/unit/clients/apim-client.test.ts @@ -5,8 +5,9 @@ * HTTP errors when a resource type is not available on the service tier. */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { ApimClient, HttpError, describeRequestTarget } from '../../../src/clients/apim-client.js'; +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; +import { ApimClient, HttpError } from '../../../src/clients/apim-client.js'; +import { ArmTokenCredential } from '../../../src/clients/arm-http-client.js'; import { ResourceType } from '../../../src/models/resource-types.js'; import { ApimServiceContext } from '../../../src/models/types.js'; import { buildArmBaseUrl } from '../../../src/lib/cloud-config.js'; @@ -23,6 +24,14 @@ const testContext: ApimServiceContext = { 'https://management.azure.com/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.ApiManagement/service/apim-1', }; +/** Credential stub so the client never acquires a real Azure token. */ +const fakeCredential: ArmTokenCredential = { + getToken: async () => ({ token: 'fake-token', expiresOnTimestamp: Date.now() + 60 * 60 * 1000 }), +}; + +/** Wait stub so retry back-off and polling delays take no time. */ +const noDelay = async (): Promise => undefined; + function makeResponse( status: number, body: unknown, @@ -47,9 +56,7 @@ describe('ApimClient.validatePreFlight', () => { ['china', 'https://management.chinacloudapi.cn'], ['germany', 'https://management.microsoftazure.de'], ])('should use the %s endpoint for both pre-flight checks', async (cloud, endpoint) => { - const client = new ApimClient(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - vi.spyOn(client as any, 'getToken').mockResolvedValue('fake-token'); + const client = new ApimClient(undefined, { credential: fakeCredential }); const fetchSpy = vi.fn().mockImplementation(() => Promise.resolve(makeResponse(200, {}))); vi.stubGlobal('fetch', fetchSpy); const context: ApimServiceContext = { @@ -98,35 +105,11 @@ describe('ApimClient.validatePreFlight', () => { } beforeEach(() => { - client = new ApimClient(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - vi.spyOn(client as any, 'getToken').mockResolvedValue('fake-token'); + client = new ApimClient(undefined, { credential: fakeCredential }); fetchSpy = vi.fn(); vi.stubGlobal('fetch', fetchSpy); }); - it('should pass when the resource group read is forbidden but the APIM service exists', async () => { - const debugSpy = vi.spyOn(logger, 'debug'); - stubResponses(() => makeResponse(403, rgForbidden), () => makeResponse(200, {})); - - await expect(client.validatePreFlight(testContext)).resolves.toBeUndefined(); - - expect(fetchSpy).toHaveBeenCalledTimes(2); - expect(fetchSpy).toHaveBeenNthCalledWith(2, serviceUrl, expect.objectContaining({ method: 'GET' })); - expect(debugSpy).toHaveBeenCalledWith( - expect.stringContaining("No permission to read resource group 'rg-1' (HTTP 403)"), - ); - }); - - it('should not warn when the resource group read is forbidden', async () => { - const warnSpy = vi.spyOn(logger, 'warn'); - stubResponses(() => makeResponse(403, rgForbidden), () => makeResponse(200, {})); - - await client.validatePreFlight(testContext); - - expect(warnSpy).not.toHaveBeenCalled(); - }); - it('should report a possibly wrong resource group when the resource group read is forbidden and the APIM service is not found', async () => { stubResponses(() => makeResponse(403, rgForbidden), () => makeResponse(404, { error: { code: 'ResourceNotFound' } })); @@ -148,15 +131,6 @@ describe('ApimClient.validatePreFlight', () => { ); }); - it('should report a missing resource group without checking the APIM service', async () => { - stubResponses(() => makeResponse(404, { error: { code: 'ResourceGroupNotFound' } }), () => makeResponse(200, {})); - - await expect(client.validatePreFlight(testContext)).rejects.toThrow( - "Resource group 'rg-1' not found in subscription 'sub-1'. Ensure the resource group exists before publishing.", - ); - expect(fetchSpy).toHaveBeenCalledTimes(1); - }); - it('should report a missing APIM service when the resource group exists', async () => { stubResponses(() => makeResponse(200, {}), () => makeResponse(404, { error: { code: 'ResourceNotFound' } })); @@ -164,25 +138,6 @@ describe('ApimClient.validatePreFlight', () => { "APIM service 'apim-1' not found in resource group 'rg-1'. Ensure the APIM instance exists before publishing.", ); }); - - it('should fail with an authentication error when the resource group read returns 401', async () => { - stubResponses(() => makeResponse(401, { error: { code: 'InvalidAuthenticationToken' } }), () => makeResponse(200, {})); - - await expect(client.validatePreFlight(testContext)).rejects.toThrow( - "Authentication failed when checking resource group 'rg-1'.", - ); - expect(fetchSpy).toHaveBeenCalledTimes(1); - }); - - it('should rethrow other resource group errors unchanged', async () => { - stubResponses(() => makeResponse(400, { error: { code: 'InvalidSubscriptionId' } }), () => makeResponse(200, {})); - - const error = await client.validatePreFlight(testContext).catch((e: unknown) => e); - - expect(error).toBeInstanceOf(HttpError); - expect((error as HttpError).status).toBe(400); - expect(fetchSpy).toHaveBeenCalledTimes(1); - }); }); }); @@ -191,13 +146,9 @@ describe('ApimClient.listResources', () => { let fetchSpy: ReturnType; beforeEach(() => { - client = new ApimClient(); + client = new ApimClient(undefined, { credential: fakeCredential }); fetchSpy = vi.fn(); vi.stubGlobal('fetch', fetchSpy); - - // @azure/identity is mocked via credential; stub getToken on the instance - // eslint-disable-next-line @typescript-eslint/no-explicit-any - vi.spyOn(client as any, 'getToken').mockResolvedValue('fake-token'); }); afterEach(() => { @@ -446,12 +397,9 @@ describe('ApimClient.getResource', () => { let fetchSpy: ReturnType; beforeEach(() => { - client = new ApimClient(); + client = new ApimClient(undefined, { credential: fakeCredential }); fetchSpy = vi.fn(); vi.stubGlobal('fetch', fetchSpy); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - vi.spyOn(client as any, 'getToken').mockResolvedValue('fake-token'); }); afterEach(() => { @@ -555,6 +503,19 @@ describe('ApimClient.getResource', () => { expect(result).toBeUndefined(); }); + it.each([ + ['a 404', ResourceType.ServicePolicy, 404], + ['a wiki 500', ResourceType.ApiWiki, 500], + ])('should cancel the unread body of %s it treats as not found', async (_label, type, status) => { + const cancel = vi.fn(); + fetchSpy.mockResolvedValueOnce(new Response(new ReadableStream({ cancel }), { status })); + + const descriptor = { type, nameParts: type === ResourceType.ApiWiki ? ['echo'] : [] }; + + await expect(client.getResource(testContext, descriptor)).resolves.toBeUndefined(); + expect(cancel).toHaveBeenCalledTimes(1); + }); + it('should return undefined on 405 (association endpoints that do not support GET)', async () => { fetchSpy.mockResolvedValueOnce( new Response( @@ -573,6 +534,32 @@ describe('ApimClient.getResource', () => { expect(result).toBeUndefined(); }); + it('should return undefined when a GET-capable resource returns 405', async () => { + fetchSpy.mockResolvedValueOnce( + new Response(JSON.stringify({ Message: "The requested resource does not support http method 'GET'." }), { + status: 405, + headers: { 'Content-Type': 'application/json' }, + }) + ); + + await expect( + client.getResource(testContext, { type: ResourceType.Api, nameParts: ['petstore'] }) + ).resolves.toBeUndefined(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('should log and rethrow any other error', async () => { + const error = vi.spyOn(logger, 'error').mockImplementation(() => undefined); + fetchSpy.mockResolvedValueOnce( + makeResponse(403, { error: { code: 'AuthorizationFailed', message: 'no access' } }) + ); + + await expect( + client.getResource(testContext, { type: ResourceType.Api, nameParts: ['petstore'] }) + ).rejects.toMatchObject({ status: 403, code: 'AuthorizationFailed' }); + expect(error).toHaveBeenCalledWith('Failed to get resource: HTTP 403: [AuthorizationFailed] no access'); + }); + it('should return undefined on HTTP 500 for ApiWiki without retrying', async () => { // Azure APIM returns 500 (not 404) for APIs that have no wiki. // getResource must treat this as "not found" and must NOT retry. @@ -643,15 +630,10 @@ describe('ApimClient.putResource provisioning polling', () => { let fetchSpy: ReturnType; beforeEach(() => { - client = new ApimClient(); + // Suppress polling delay to keep tests fast + client = new ApimClient(undefined, { credential: fakeCredential, delay: noDelay }); fetchSpy = vi.fn(); vi.stubGlobal('fetch', fetchSpy); - - /* eslint-disable @typescript-eslint/no-explicit-any */ - vi.spyOn(client as any, 'getToken').mockResolvedValue('fake-token'); - // Suppress polling delay to keep tests fast - vi.spyOn(client as any, 'delay').mockResolvedValue(undefined); - /* eslint-enable @typescript-eslint/no-explicit-any */ }); afterEach(() => { @@ -662,6 +644,43 @@ describe('ApimClient.putResource provisioning polling', () => { const descriptor = { type: ResourceType.NamedValue, nameParts: ['my-nv'] }; const succeededResource = { name: 'my-nv', properties: { provisioningState: 'Succeeded' } }; + it('should cancel the unread PUT body when it polls an async operation', async () => { + const cancel = vi.fn(); + fetchSpy + .mockResolvedValueOnce(new Response(new ReadableStream({ cancel }), { + status: 201, + headers: { 'Azure-AsyncOperation': 'https://management.azure.com/operations/op-1' }, + })) + .mockResolvedValueOnce(makeResponse(200, { status: 'Succeeded' })) + .mockResolvedValueOnce(makeResponse(200, succeededResource)); + + await expect(client.putResource(testContext, descriptor, {})).resolves.toEqual(succeededResource); + expect(cancel).toHaveBeenCalledTimes(1); + }); + + it('should return an empty object when the resource is gone once the async operation completes', async () => { + fetchSpy + .mockResolvedValueOnce(new Response('{}', { + status: 201, + headers: { 'Azure-AsyncOperation': 'https://management.azure.com/operations/op-1' }, + })) + .mockResolvedValueOnce(makeResponse(200, { status: 'Succeeded' })) + .mockResolvedValueOnce(makeResponse(404, { error: { code: 'ResourceNotFound' } })); + + await expect(client.putResource(testContext, descriptor, {})).resolves.toEqual({}); + expect(fetchSpy).toHaveBeenCalledTimes(3); + }); + + it('should cancel the unread body of a headerless 202 PUT before polling its provisioning state', async () => { + const cancel = vi.fn(); + fetchSpy + .mockResolvedValueOnce(new Response(new ReadableStream({ cancel }), { status: 202 })) + .mockResolvedValueOnce(makeResponse(200, succeededResource)); + + await expect(client.putResource(testContext, descriptor, {})).resolves.toEqual(succeededResource); + expect(cancel).toHaveBeenCalledTimes(1); + }); + it('should strip the top-level source ARM id from the PUT body', async () => { fetchSpy.mockResolvedValueOnce(makeResponse(200, succeededResource)); @@ -789,6 +808,44 @@ describe('ApimClient.putResource provisioning polling', () => { expect(fetchSpy).toHaveBeenCalledTimes(1); }); + it('should accept a Succeeded provisioningState case-insensitively after a 201', async () => { + const settled = { name: 'my-nv', properties: { provisioningState: 'succeeded' } }; + fetchSpy + .mockResolvedValueOnce(makeResponse(201, { name: 'my-nv' })) + .mockResolvedValueOnce(makeResponse(200, { name: 'my-nv', properties: { provisioningState: 'Creating' } })) + .mockResolvedValueOnce(makeResponse(200, settled)); + + await expect(client.putResource(testContext, descriptor, {})).resolves.toEqual(settled); + expect(fetchSpy).toHaveBeenCalledTimes(3); + }); + + it('should give up on a PUT after 30 provisioning polls', async () => { + fetchSpy + .mockResolvedValueOnce(makeResponse(201, { name: 'my-nv' })) + .mockImplementation(async () => + makeResponse(200, { name: 'my-nv', properties: { provisioningState: 'Creating' } }) + ); + + await expect(client.putResource(testContext, descriptor, {})).rejects.toThrow( + 'Provisioning state polling timed out' + ); + expect(fetchSpy).toHaveBeenCalledTimes(31); + }); + + it.each(['Canceled', 'Cancelled', 'FAILED'])( + 'should fail when provisioning after a 201 reports %s', + async (state) => { + fetchSpy + .mockResolvedValueOnce(makeResponse(201, { name: 'my-nv' })) + .mockResolvedValueOnce(makeResponse(200, { name: 'my-nv', properties: { provisioningState: state } })); + + await expect(client.putResource(testContext, descriptor, {})).rejects.toThrow( + `Resource provisioning ${state.toLowerCase()}:` + ); + expect(fetchSpy).toHaveBeenCalledTimes(2); + } + ); + it('should poll when PUT returns 201 and eventually return the resource', async () => { fetchSpy // Initial PUT → 201 @@ -919,23 +976,29 @@ describe('ApimClient.deleteResource provisioning polling', () => { let fetchSpy: ReturnType; beforeEach(() => { - client = new ApimClient(); + client = new ApimClient(undefined, { credential: fakeCredential, delay: noDelay }); fetchSpy = vi.fn(); vi.stubGlobal('fetch', fetchSpy); - - /* eslint-disable @typescript-eslint/no-explicit-any */ - vi.spyOn(client as any, 'getToken').mockResolvedValue('fake-token'); - vi.spyOn(client as any, 'delay').mockResolvedValue(undefined); - /* eslint-enable @typescript-eslint/no-explicit-any */ }); afterEach(() => { + vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); const descriptor = { type: ResourceType.Api, nameParts: ['delay'] }; + it.each([200, 202])('should cancel the unread body of a %i DELETE response', async (status) => { + const cancel = vi.fn(); + fetchSpy + .mockResolvedValueOnce(new Response(new ReadableStream({ cancel }), { status })) + .mockResolvedValueOnce(new Response('', { status: 404 })); + + await expect(client.deleteResource(testContext, descriptor)).resolves.toBe(true); + expect(cancel).toHaveBeenCalledTimes(1); + }); + it('should treat 404 during delete polling as successful completion', async () => { fetchSpy // Initial DELETE accepted asynchronously @@ -967,6 +1030,75 @@ describe('ApimClient.deleteResource provisioning polling', () => { expect(deleted).toBe(true); expect(fetchSpy).toHaveBeenCalledTimes(3); }); + + it('should keep polling while the resource is still returned without a provisioningState', async () => { + const body = { name: 'delay', properties: {} }; + fetchSpy + .mockResolvedValueOnce(new Response('', { status: 202 })) + .mockResolvedValueOnce(makeResponse(200, body)) + .mockResolvedValueOnce(makeResponse(200, body)) + .mockResolvedValueOnce(new Response('', { status: 404 })); + + const deleted = await client.deleteResource(testContext, descriptor); + + expect(deleted).toBe(true); + expect(fetchSpy).toHaveBeenCalledTimes(4); + }); + + /** Client whose delays advance the (fake) clock instead of waiting. */ + function clockClient(): ApimClient { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + return new ApimClient(undefined, { + credential: fakeCredential, + delay: async (ms) => { + vi.setSystemTime(Date.now() + ms); + }, + }); + } + + it('should wait 2 seconds before each GET', async () => { + const delays: number[] = []; + const recording = new ApimClient(undefined, { + credential: fakeCredential, + delay: async (ms) => { + delays.push(ms); + }, + }); + fetchSpy.mockResolvedValueOnce(new Response('', { status: 202 })); + for (let i = 0; i < 3; i++) { + fetchSpy.mockResolvedValueOnce(makeResponse(200, { name: 'delay', properties: {} })); + } + fetchSpy.mockResolvedValueOnce(new Response('', { status: 404 })); + + await expect(recording.deleteResource(testContext, descriptor)).resolves.toBe(true); + expect(delays).toEqual([2000, 2000, 2000, 2000]); + }); + + it('should time out after 30 checks 2 seconds apart when the resource never disappears', async () => { + fetchSpy + .mockResolvedValueOnce(new Response('', { status: 202 })) + .mockImplementation(async () => makeResponse(200, { name: 'delay', properties: {} })); + const clocked = clockClient(); + const start = Date.now(); + + await expect(clocked.deleteResource(testContext, descriptor)).rejects.toThrow( + 'apis/delay was not deleted within 1m 0.0s: the DELETE was accepted but the resource is still present.' + ); + expect(Date.now() - start).toBeGreaterThanOrEqual(60 * 1000); + // The DELETE plus a GET every 2s for 60s, the provisioning-state poll's limit. + expect(fetchSpy).toHaveBeenCalledTimes(31); + }); + + it('should keep polling a resource that reports Canceled until it is gone', async () => { + fetchSpy + .mockResolvedValueOnce(new Response('', { status: 202 })) + .mockResolvedValueOnce(makeResponse(200, { name: 'delay', properties: { provisioningState: 'Canceled' } })) + .mockResolvedValueOnce(new Response('', { status: 404 })); + + await expect(client.deleteResource(testContext, descriptor)).resolves.toBe(true); + expect(fetchSpy).toHaveBeenCalledTimes(3); + }); }); describe('ApimClient.deleteResource revision and reference handling', () => { @@ -974,14 +1106,9 @@ describe('ApimClient.deleteResource revision and reference handling', () => { let fetchSpy: ReturnType; beforeEach(() => { - client = new ApimClient(); + client = new ApimClient(undefined, { credential: fakeCredential, delay: noDelay }); fetchSpy = vi.fn(); vi.stubGlobal('fetch', fetchSpy); - - /* eslint-disable @typescript-eslint/no-explicit-any */ - vi.spyOn(client as any, 'getToken').mockResolvedValue('fake-token'); - vi.spyOn(client as any, 'delay').mockResolvedValue(undefined); - /* eslint-enable @typescript-eslint/no-explicit-any */ }); afterEach(() => { @@ -1118,15 +1245,10 @@ describe('ApimClient HTTP 429 rate limiting', () => { let fetchSpy: ReturnType; beforeEach(() => { - client = new ApimClient(); + // Suppress delay to keep tests fast + client = new ApimClient(undefined, { credential: fakeCredential, delay: noDelay }); fetchSpy = vi.fn(); vi.stubGlobal('fetch', fetchSpy); - - /* eslint-disable @typescript-eslint/no-explicit-any */ - vi.spyOn(client as any, 'getToken').mockResolvedValue('fake-token'); - // Suppress delay to keep tests fast - vi.spyOn(client as any, 'delay').mockResolvedValue(undefined); - /* eslint-enable @typescript-eslint/no-explicit-any */ }); afterEach(() => { @@ -1157,41 +1279,6 @@ describe('ApimClient HTTP 429 rate limiting', () => { expect(fetchSpy).toHaveBeenCalledTimes(2); }); - it('should retry on HTTP 429 without Retry-After header using exponential backoff', async () => { - fetchSpy - .mockResolvedValueOnce( - new Response('', { status: 429 }) - ) - .mockResolvedValueOnce( - makeResponse(200, { name: 'nv-1', properties: { value: 'test' } }) - ); - - const descriptor = { - type: ResourceType.NamedValue, - nameParts: ['nv-1'], - }; - - const result = await client.getResource(testContext, descriptor); - - expect(result).toEqual({ name: 'nv-1', properties: { value: 'test' } }); - expect(fetchSpy).toHaveBeenCalledTimes(2); - }); - - it('should fail after max retries on repeated 429 responses', async () => { - fetchSpy.mockResolvedValue( - new Response('', { status: 429, headers: { 'Retry-After': '1' } }) - ); - - const descriptor = { - type: ResourceType.NamedValue, - nameParts: ['nv-1'], - }; - - await expect(client.getResource(testContext, descriptor)).rejects.toThrow(); - // MAX_RETRIES is 3, so we expect 4 attempts (initial + 3 retries) - expect(fetchSpy).toHaveBeenCalledTimes(4); - }); - it('should retry 429 on PUT operations', async () => { fetchSpy .mockResolvedValueOnce( @@ -1221,14 +1308,9 @@ describe('ApimClient HTTP 409 conflict handling', () => { let fetchSpy: ReturnType; beforeEach(() => { - client = new ApimClient(); + client = new ApimClient(undefined, { credential: fakeCredential, delay: noDelay }); fetchSpy = vi.fn(); vi.stubGlobal('fetch', fetchSpy); - - /* eslint-disable @typescript-eslint/no-explicit-any */ - vi.spyOn(client as any, 'getToken').mockResolvedValue('fake-token'); - vi.spyOn(client as any, 'delay').mockResolvedValue(undefined); - /* eslint-enable @typescript-eslint/no-explicit-any */ }); afterEach(() => { @@ -1287,15 +1369,10 @@ describe('ApimClient.getApiSpecification', () => { let fetchSpy: ReturnType; beforeEach(() => { - client = new ApimClient(); + // Suppress delays to keep tests fast + client = new ApimClient(undefined, { credential: fakeCredential, delay: noDelay }); fetchSpy = vi.fn(); vi.stubGlobal('fetch', fetchSpy); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - vi.spyOn(client as any, 'getToken').mockResolvedValue('fake-token'); - // Suppress delays to keep tests fast - // eslint-disable-next-line @typescript-eslint/no-explicit-any - vi.spyOn(client as any, 'delay').mockResolvedValue(undefined); }); afterEach(() => { @@ -1497,6 +1574,47 @@ describe('ApimClient.getApiSpecification', () => { expect(fetchSpy).toHaveBeenCalledTimes(1); }); + it('should cancel the unread body of a 404 export response', async () => { + const cancel = vi.fn(); + fetchSpy.mockResolvedValueOnce(new Response(new ReadableStream({ cancel }), { status: 404 })); + + await expect(client.getApiSpecification(testContext, 'nonexistent-api')).resolves.toBeUndefined(); + expect(cancel).toHaveBeenCalledTimes(1); + }); + + it('should warn, skip the spec and release the body when the spec blob returns 404', async () => { + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => undefined); + const cancel = vi.fn(); + fetchSpy + .mockResolvedValueOnce( + new Response( + JSON.stringify({ format: 'openapi-link', link: 'https://blob.storage.example.com/specs/pet-store.yaml?sig=abc' }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ) + .mockResolvedValueOnce(new Response(new ReadableStream({ cancel }), { status: 404 })); + + await expect(client.getApiSpecification(testContext, 'pet-store')).resolves.toBeUndefined(); + expect(cancel).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith('Failed to fetch specification blob for pet-store: HTTP 404'); + }); + + it('should skip the spec and release the body when the inline wsdl export returns 404', async () => { + const cancel = vi.fn(); + fetchSpy + .mockResolvedValueOnce( + new Response(JSON.stringify({ error: { code: 'InternalServerError' } }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }) + ) + .mockResolvedValueOnce(new Response(new ReadableStream({ cancel }), { status: 404 })); + + await expect(client.getApiSpecification(testContext, 'soap-api', 'soap')).resolves.toBeUndefined(); + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(cancel).toHaveBeenCalledTimes(1); + }); + it('should return undefined (without throwing) when GraphQL export returns 406', async () => { // GraphQL APIs defined via inline schema (no downloadable SDL link) return // HTTP 406 from format=graphql-link. This is an expected "no spec available" @@ -1733,12 +1851,9 @@ describe('User-Agent header', () => { let fetchSpy: ReturnType; beforeEach(() => { - client = new ApimClient(); + client = new ApimClient(undefined, { credential: fakeCredential }); fetchSpy = vi.fn(); vi.stubGlobal('fetch', fetchSpy); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - vi.spyOn(client as any, 'getToken').mockResolvedValue('fake-token'); }); afterEach(() => { @@ -1786,38 +1901,16 @@ describe('User-Agent header', () => { }); }); -describe('describeRequestTarget', () => { - it('reduces ARM URLs to the path below the APIM service', () => { - expect( - describeRequestTarget(`${testContext.baseUrl}/namedValues/nv%201?api-version=2024-05-01`) - ).toBe('namedValues/nv 1'); - }); - - it('describes the service itself when no sub-path is present', () => { - expect(describeRequestTarget(`${testContext.baseUrl}?api-version=2024-05-01`)).toBe('service'); - }); - - it('keeps host and path for non-ARM URLs and drops the query string', () => { - expect(describeRequestTarget('https://blob.example.net/container/spec.json?sig=secret')).toBe( - 'blob.example.net/container/spec.json' - ); - }); -}); - describe('ApimClient retry logging', () => { let client: ApimClient; let fetchSpy: ReturnType; beforeEach(() => { - client = new ApimClient(); + client = new ApimClient(undefined, { credential: fakeCredential, delay: noDelay }); fetchSpy = vi.fn(); vi.stubGlobal('fetch', fetchSpy); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - vi.spyOn(client as any, 'getToken').mockResolvedValue('fake-token'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - vi.spyOn(client as any, 'delay').mockResolvedValue(undefined); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - vi.spyOn(client as any, 'exponentialBackoffWithJitter').mockReturnValue(1175.1730686888397); + // Back-off of 1000 ms plus 20% jitter + vi.spyOn(Math, 'random').mockReturnValue(2 / 3); }); afterEach(() => { @@ -1872,3 +1965,50 @@ describe('ApimClient retry logging', () => { ); }); }); + +describe('ApimClient shared ARM client behaviour', () => { + let client: ApimClient; + let fetchSpy: ReturnType; + let delaySpy: Mock<(ms: number) => Promise>; + + beforeEach(() => { + delaySpy = vi.fn<(ms: number) => Promise>().mockResolvedValue(undefined); + client = new ApimClient(undefined, { credential: fakeCredential, delay: delaySpy }); + fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + vi.spyOn(logger, 'warn').mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + const descriptor = { type: ResourceType.NamedValue, nameParts: ['my-nv'] }; + const operationUrl = 'https://management.azure.com/operations/op-1'; + + it('should treat a 404 from a DELETE async operation as completion', async () => { + fetchSpy + .mockResolvedValueOnce(new Response('', { status: 202, headers: { 'Azure-AsyncOperation': operationUrl } })) + .mockResolvedValueOnce(makeResponse(404, { error: { code: 'NotFound' } })); + + await expect(client.deleteResource(testContext, descriptor)).resolves.toBe(true); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it('should refuse to follow a nextLink to a non-ARM host', async () => { + fetchSpy.mockResolvedValueOnce( + makeResponse(200, { value: [{ name: 'gw-1' }], nextLink: 'https://attacker.example.com/gateways?page=2' }) + ); + + const results: unknown[] = []; + await expect(async () => { + for await (const item of client.listResources(testContext, ResourceType.Gateway)) { + results.push(item); + } + }).rejects.toThrow(/Refusing to follow nextLink/); + expect(results).toHaveLength(1); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/clients/arm-http-client.test.ts b/tests/unit/clients/arm-http-client.test.ts new file mode 100644 index 00000000..10b32267 --- /dev/null +++ b/tests/unit/clients/arm-http-client.test.ts @@ -0,0 +1,1294 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +/** + * Unit tests for the shared ARM HTTP client: error parsing, Retry-After, + * host validation, token caching, request retries, pagination and + * long-running operation polling. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + ArmHttpClient, + HttpError, + maskSasSignature, + ArmTokenCredential, + describeRequestTarget, + extractAsyncOperation, + isArmUrl, + paginateArm, + parseArmError, + parseRetryAfterMs, + pollArmOperation, + readArmState, + waitUntilGone, +} from '../../../src/clients/arm-http-client.js'; +import { HttpError as ApimHttpError } from '../../../src/clients/apim-client.js'; +import { getArmHosts } from '../../../src/lib/cloud-config.js'; +import { ApicHttpError } from '../../../src/clients/apic-client.js'; +import { logger } from '../../../src/lib/logger.js'; +import { trackRetries } from '../../../src/lib/retry-tracker.js'; +import { USER_AGENT } from '../../../src/lib/user-agent.js'; + +const ARM = 'https://management.azure.com'; +const URL_1 = `${ARM}/subscriptions/sub-1/resourceGroups/rg-1?api-version=2021-04-01`; +const OPERATION_URL = `${ARM}/subscriptions/sub-1/providers/Microsoft.ApiCenter/operationStatuses/op-1`; + +function json(status: number, body: unknown, headers: Record = {}): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json', ...headers }, + }); +} + +function empty(status: number, headers: Record = {}): Response { + return new Response(status === 204 ? null : '', { status, headers }); +} + +/** A response with the given status whose body records whether it was cancelled. */ +function unread(status: number, cancel: () => void): Response { + return new Response(new ReadableStream({ cancel }), { status }); +} + +function credential(token = 'fake-token', ttlMs = 60 * 60 * 1000): ArmTokenCredential & { + getToken: ReturnType; +} { + return { + getToken: vi.fn(async () => ({ token, expiresOnTimestamp: Date.now() + ttlMs })), + }; +} + +describe('HttpError', () => { + it('should carry status, code and body', () => { + const error = new HttpError(409, 'HTTP 409: conflict', 'Conflict', { error: {} }); + expect(error).toBeInstanceOf(Error); + expect(error).toMatchObject({ status: 409, code: 'Conflict', body: { error: {} }, name: 'HttpError' }); + }); + + it('should be the class the APIM client exports and ApicHttpError aliases', () => { + expect(ApimHttpError).toBe(HttpError); + expect(new ApicHttpError(400, 'x')).toBeInstanceOf(HttpError); + expect(new HttpError(400, 'x')).toBeInstanceOf(ApicHttpError); + }); +}); + +describe('parseArmError', () => { + it('should return the ARM error code, message and body', () => { + const text = JSON.stringify({ error: { code: 'ValidationError', message: 'bad' } }); + expect(parseArmError(text)).toEqual({ + code: 'ValidationError', + message: 'bad', + details: [], + body: { error: { code: 'ValidationError', message: 'bad' } }, + }); + }); + + it('should return the messages of the error details, after their target', () => { + const body = { + error: { + code: 'ValidationError', + message: 'One or more fields contain incorrect values:', + details: [ + { code: 'ValidationError', target: 'representation', message: 'Content type is invalid' }, + { code: 'ValidationError', message: 'Value is required' }, + { code: 'ValidationError', target: 'ignored' }, + 'not an object', + ], + }, + }; + expect(parseArmError(JSON.stringify(body)).details).toEqual([ + 'representation: Content type is invalid', + 'Value is required', + ]); + }); + + it.each([ + ['no error object', { message: 'x' }], + ['a non-string code and message', { error: { code: 42, message: { text: 'x' } } }], + ['a null error', { error: null }], + ])('should return the body without a code or message for %s', (_label, body) => { + expect(parseArmError(JSON.stringify(body))).toEqual({ + code: undefined, + message: undefined, + details: [], + body, + }); + }); + + it('should return nothing for a non-JSON body', () => { + expect(parseArmError('Service Unavailable')).toEqual({}); + }); +}); + +describe('parseRetryAfterMs', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it.each([ + ['5', 5000], + ['1.5', 1500], + ['-1', 0], + ])('should parse delay-seconds %j as the Azure SDK does', (value, expected) => { + expect(parseRetryAfterMs(value)).toBe(expected); + }); + + it('should parse an HTTP-date relative to now', () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + expect(parseRetryAfterMs('Thu, 01 Jan 2026 00:00:30 GMT')).toBe(30_000); + }); + + it('should not bound an HTTP-date or delay-seconds', () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + expect(parseRetryAfterMs('Thu, 01 Jan 2026 01:00:00 GMT')).toBe(3_600_000); + expect(parseRetryAfterMs('3600')).toBe(3_600_000); + }); + + it('should clamp an HTTP-date in the past to zero', () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + expect(parseRetryAfterMs('Wed, 31 Dec 2025 23:59:00 GMT')).toBe(0); + }); + + it.each([null, '', 'soon', 'Infinity'])('should return undefined for %j', (value) => { + expect(parseRetryAfterMs(value)).toBeUndefined(); + }); +}); + +describe('isArmUrl', () => { + it.each([ + 'https://management.azure.com/subscriptions/x', + 'https://eastus.management.azure.com/x', + 'https://MANAGEMENT.AZURE.COM/x', + ])('should accept %s on a public-cloud ARM host', (url) => { + expect(isArmUrl(url, getArmHosts())).toBe(true); + }); + + it.each([ + 'http://management.azure.com/x', + 'https://management.azure.com.attacker.example/x', + 'https://notmanagement.azure.com/x', + 'https://blob.core.windows.net/x', + 'not a url', + ])('should reject %s', (url) => { + expect(isArmUrl(url, getArmHosts())).toBe(false); + }); + + it('should accept only the given hosts', () => { + const hosts = ['management.usgovcloudapi.net']; + + expect(isArmUrl('https://management.usgovcloudapi.net/x', hosts)).toBe(true); + expect(isArmUrl('https://management.azure.com/x', hosts)).toBe(false); + }); +}); + +describe('describeRequestTarget', () => { + const APIM_BASE_URL = + `${ARM}/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.ApiManagement/service/apim-1`; + + it('should reduce API Center URLs to the path below the service', () => { + expect( + describeRequestTarget( + `${ARM}/subscriptions/s/resourceGroups/r/providers/Microsoft.ApiCenter/services/apic-1` + + '/workspaces/default/apis/pet%20store?api-version=2024-06-01-preview', + ), + ).toBe('workspaces/default/apis/pet store'); + }); + + it('should describe an API Center service root as the service', () => { + expect( + describeRequestTarget( + `${ARM}/subscriptions/s/resourceGroups/r/providers/Microsoft.ApiCenter/services/apic-1?api-version=x`, + ), + ).toBe('service'); + }); + + it('should keep host and path for other ARM URLs', () => { + expect(describeRequestTarget(URL_1)).toBe('management.azure.com/subscriptions/sub-1/resourceGroups/rg-1'); + }); + + it('should reduce APIM URLs to the path below the service', () => { + expect( + describeRequestTarget(`${APIM_BASE_URL}/namedValues/nv%201?api-version=2024-05-01`), + ).toBe('namedValues/nv 1'); + }); + + it('should describe an APIM service root as the service', () => { + expect(describeRequestTarget(`${APIM_BASE_URL}?api-version=2024-05-01`)).toBe('service'); + }); + + it('should read a value that is not an absolute URL as a path', () => { + expect( + describeRequestTarget( + '/subscriptions/s/resourceGroups/r/providers/Microsoft.ApiManagement/service/a/apis/echo%20api?sig=secret', + ), + ).toBe('apis/echo api'); + }); + + it('should keep a path with broken percent-encoding as it is', () => { + expect(describeRequestTarget(`${APIM_BASE_URL}/apis/bad%E0%A4%A`)).toBe('apis/bad%E0%A4%A'); + }); + + it('should keep host and path for non-ARM URLs and drop the query string', () => { + expect(describeRequestTarget('https://blob.example.net/container/spec.json?sig=secret')).toBe( + 'blob.example.net/container/spec.json', + ); + }); +}); + +// Shared by the ArmHttpClient, pagination and LRO polling tests below: fetch +// is stubbed, the credential is a stub and delays are recorded, not waited. +let fetchSpy: ReturnType; +let delays: number[]; +let cred: ReturnType; + +function client(): ArmHttpClient { + return new ArmHttpClient({ + credential: cred, + delay: async (ms) => { + delays.push(ms); + }, + }); +} + +function headersOf(n: number): Headers { + return new Headers((fetchSpy.mock.calls[n][1] as RequestInit).headers); +} + +beforeEach(() => { + fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + delays = []; + cred = credential(); + // No jitter: back-off is exactly 1s, 2s, 4s. + vi.spyOn(Math, 'random').mockReturnValue(0); + vi.spyOn(logger, 'warn').mockImplementation(() => undefined); + vi.spyOn(logger, 'debug').mockImplementation(() => undefined); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('ArmHttpClient.getToken', () => { + it('should default to the public cloud scope', async () => { + fetchSpy.mockResolvedValueOnce(json(200, {})); + + await client().request(URL_1); + + expect(cred.getToken).toHaveBeenCalledWith('https://management.azure.com/.default'); + }); + + it('should use the configured scope', async () => { + const http = new ArmHttpClient({ + authScope: 'https://management.usgovcloudapi.net/.default', + credential: cred, + }); + + await http.getToken(); + + expect(cred.getToken).toHaveBeenCalledWith('https://management.usgovcloudapi.net/.default'); + }); + + it("should refresh after the credential's refreshAfterTimestamp", async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + const start = Date.now(); + cred.getToken + .mockResolvedValueOnce({ + token: 't1', + expiresOnTimestamp: start + 60 * 60 * 1000, + refreshAfterTimestamp: start + 30 * 60 * 1000, + }) + .mockResolvedValueOnce({ token: 't2', expiresOnTimestamp: start + 120 * 60 * 1000 }); + const http = client(); + + await http.getToken(); + vi.setSystemTime(start + 31 * 60 * 1000); + expect(await http.getToken()).toBe('t1'); + await new Promise((resolve) => setImmediate(resolve)); + + expect(await http.getToken()).toBe('t2'); + }); + + it('should not cache a token without an expiry', async () => { + cred.getToken.mockResolvedValue({ token: 't' }); + const http = client(); + + await http.getToken(); + await http.getToken(); + + expect(cred.getToken).toHaveBeenCalledTimes(2); + }); + + it('should throw when the credential returns no token', async () => { + cred.getToken.mockResolvedValue(null); + + await expect(client().getToken()).rejects.toThrow('Failed to acquire an ARM token'); + }); +}); + +describe('ArmHttpClient.request headers', () => { + it('should send Authorization, Content-Type and User-Agent', async () => { + fetchSpy.mockResolvedValueOnce(json(200, {})); + + await client().request(URL_1, { method: 'PUT', body: '{}' }); + + const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(URL_1); + expect(init.method).toBe('PUT'); + expect(init.body).toBe('{}'); + const headers = headersOf(0); + expect(headers.get('Authorization')).toBe('Bearer fake-token'); + expect(headers.get('Content-Type')).toBe('application/json'); + expect(headers.get('User-Agent')).toBe(USER_AGENT); + }); + + it('should strip all credentials and skip the token with skipAuth', async () => { + fetchSpy.mockResolvedValueOnce(new Response('spec', { status: 200 })); + + await client().request( + 'https://blob.example.net/spec.yaml?sig=secret', + { + headers: { + Authorization: 'Bearer leaked', + 'Proxy-Authorization': 'Basic leaked', + 'x-ms-authorization-auxiliary': 'Bearer leaked', + Accept: 'text/plain', + }, + }, + { skipAuth: true }, + ); + + const headers = headersOf(0); + expect(headers.get('Authorization')).toBeNull(); + expect(headers.get('Proxy-Authorization')).toBeNull(); + expect(headers.get('x-ms-authorization-auxiliary')).toBeNull(); + expect(headers.get('Content-Type')).toBeNull(); + expect(headers.get('Accept')).toBe('text/plain'); + expect(headers.get('User-Agent')).toBe(USER_AGENT); + expect(cred.getToken).not.toHaveBeenCalled(); + }); +}); + +describe('maskSasSignature', () => { + // A signature of the real form (base64 of a 32-byte HMAC-SHA256). + const signature = '++++QUJD++++QUJD++++QUJD++++QUJD++++QUJD++8='; + const encoded = encodeURIComponent(signature); + const url = `https://blob.example.net/a?sv=1&sig=${encoded}&se=2`; + + it.each([ + ['as sent', `x ${encoded} y`, 'x *** y'], + ['as sent, with lower-case escapes', `x ${encoded.toLowerCase()} y`, 'x *** y'], + ['decoded', `blocked (${signature})`, 'blocked (***)'], + [ + 'in a link to the percent-encoded URL', + `href="https%3A%2F%2Fblob.example.net%2Fa%3Fsv%3D1%26sig%3D${encodeURIComponent(encoded)}%26se%3D2"`, + 'href="https%3A%2F%2Fblob.example.net%2Fa%3Fsv%3D1%26sig%3D***%26se%3D2"', + ], + ['in another sig= parameter', 'GET /a?sv=1&sig=other123&se=2', 'GET /a?sv=1&sig=***&se=2'], + ['in an HTML-escaped link', 'href="/a?sv=1&sig=other123&se=2"', 'href="/a?sv=1&sig=***&se=2"'], + ['at the start of a line', 'line\nsig=zzz', 'line\nsig=***'], + // Another sig value, so only the parameter rule can mask these. + [ + 'with sig first in a percent-encoded URL', + encodeURIComponent('https://blob.example.net/a?sig=other123&se=2'), + 'https%3A%2F%2Fblob.example.net%2Fa%3Fsig%3D***%26se%3D2', + ], + [ + 'in a URL percent-encoded twice', + encodeURIComponent(encodeURIComponent('https://blob.example.net/a?sv=1&sig=other123&se=2')), + 'https%253A%252F%252Fblob.example.net%252Fa%253Fsv%253D1%2526sig%253D***%2526se%253D2', + ], + [ + 'with sig first in a URL percent-encoded twice', + encodeURIComponent(encodeURIComponent('https://blob.example.net/a?sig=other123&se=2')), + 'https%253A%252F%252Fblob.example.net%252Fa%253Fsig%253D***%2526se%253D2', + ], + ['ending at a semicolon', 'GET /a?sv=1;sig=abc;se=2', 'GET /a?sv=1;sig=***;se=2'], + ['ending at a bracket or full stop', '[/a?sig=abc] and sig=def. Retry', '[/a?sig=***] and sig=***. Retry'], + [ + 'in a JSON-escaped URL', + '{"url":"https://blob.example.net/a?sv=1\\u0026sig=other123\\u0026se=2"}', + '{"url":"https://blob.example.net/a?sv=1\\u0026sig=***\\u0026se=2"}', + ], + ])('should mask the signature %s', (_label, text, expected) => { + expect(maskSasSignature(text, url)).toBe(expected); + }); + + it("should mask a signature sent with an unencoded '+'", () => { + const unencoded = `https://blob.example.net/a?sv=1&sig=${signature}&se=2`; + + expect(maskSasSignature(`blocked (${signature})`, unencoded)).toBe('blocked (***)'); + }); + + it('should mask the signature quoted JSON-escaped', () => { + // '/' and '+' too, which JSON encoders can escape as \\/ and \\u002B. + const slashed = '+/+/+/+/+/+/+/+/+/+/+/+/+/+/+/+/+/+/+/+/+/8='; + const jsonUrl = `https://blob.example.net/a?sig=${encodeURIComponent(slashed)}`; + const slashEscaped = slashed.replace(/\//g, '\\/'); + const bothEscaped = slashEscaped.replace(/\+/g, '\\u002B'); + + expect(maskSasSignature(`{"detail":"bad ${slashEscaped}"}`, jsonUrl)).toBe('{"detail":"bad ***"}'); + expect(maskSasSignature(`{"detail":"bad ${bothEscaped}"}`, jsonUrl)).toBe('{"detail":"bad ***"}'); + expect(maskSasSignature(`{"detail":"bad ${bothEscaped.toLowerCase()}"}`, jsonUrl)).not.toContain('u002b'); + }); + + it('should mask a short hand-made signature only as a sig parameter', () => { + expect(maskSasSignature('a banana at /a?sig=a', 'https://blob.example.net/a?sig=a')).toBe('a banana at /a?sig=***'); + }); + + it('should leave text without a signature alone', () => { + expect(maskSasSignature('BlobNotFound', url)).toBe('BlobNotFound'); + }); +}); + +describe('ArmHttpClient.request errors and retries', () => { + it('should mask the signature in a failed SAS download, naming the URL without its query', async () => { + const signature = '++++QUJD++++QUJD++++QUJD++++QUJD++++QUJD++8='; + // A proxy's block page that quotes the request URL, signature and all. + const blockPage = + 'Request to https://blob.example.net/spec.yaml?sv=2024-01-01&' + + `sig=${encodeURIComponent(signature)} was blocked (${signature})`; + fetchSpy.mockResolvedValueOnce(new Response(blockPage, { status: 403 })); + + const error = await client() + .request(`https://blob.example.net/spec.yaml?sv=2024-01-01&sig=${encodeURIComponent(signature)}`, {}, { skipAuth: true }) + .catch((e: unknown) => e); + + expect(error).toBeInstanceOf(HttpError); + const { message, body } = error as HttpError; + expect(message).toMatch(/^HTTP 403 from https:\/\/blob\.example\.net\/spec\.yaml: .*was blocked/); + expect(message).toContain('&sig=*** was blocked (***)'); + for (const text of [message, String(body)]) { + expect(text).not.toContain('QUJD'); + } + }); + + it("should keep a firewall's block page for a failed SAS download", async () => { + fetchSpy.mockResolvedValueOnce(new Response('Access to blob.example.net is blocked by policy', { status: 403 })); + + const error = await client() + .request('https://blob.example.net/spec.yaml?sig=abc', {}, { skipAuth: true }) + .catch((e: unknown) => e); + + expect((error as Error).message).toBe( + 'HTTP 403 from https://blob.example.net/spec.yaml: Access to blob.example.net is blocked by policy' + ); + }); + + it('should throw HttpError with code and body for a 4xx without retrying', async () => { + fetchSpy.mockResolvedValueOnce(json(400, { error: { code: 'ValidationError', message: 'bad' } })); + + const error = await client().request(URL_1, { method: 'PUT' }).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(HttpError); + expect(error).toMatchObject({ + status: 400, + code: 'ValidationError', + body: { error: { code: 'ValidationError', message: 'bad' } }, + }); + expect((error as Error).message).toBe('HTTP 400: [ValidationError] bad'); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['a code only', { error: { code: 'Conflict' } }, 'HTTP 409: [Conflict]'], + ['a message only', { error: { message: 'busy' } }, 'HTTP 409: busy'], + ['no ARM error object', { detail: 'x' }, 'HTTP 409: {"detail":"x"}'], + [ + 'details after a message ending in a colon', + { error: { code: 'ValidationError', message: 'One or more fields contain incorrect values:', details: [{ target: 'name', message: 'Too long' }, { message: 'Bad' }] } }, + 'HTTP 409: [ValidationError] One or more fields contain incorrect values: name: Too long; Bad', + ], + ['details after a message', { error: { message: 'Invalid', details: [{ message: 'Bad' }] } }, 'HTTP 409: Invalid: Bad'], + ['details only', { error: { details: [{ message: 'Bad' }] } }, 'HTTP 409: Bad'], + [ + 'nested details', + { error: { code: 'InvalidTemplate', message: 'Failed:', details: [{ code: 'Outer', details: [{ target: 'body', message: 'Missing' }] }, { message: 'Second', details: [{ message: 'Inner' }] }] } }, + 'HTTP 409: [InvalidTemplate] Failed: body: Missing; Second; Inner', + ], + ])('should build the message from %s', async (_label, body, message) => { + fetchSpy.mockResolvedValueOnce(json(409, body)); + + const error = await client().request(URL_1, { method: 'PUT' }).catch((e: unknown) => e); + + expect((error as Error).message).toBe(message); + expect((error as HttpError).body).toEqual(body); + }); + + it('should truncate a long body in the message and keep it in full on body', async () => { + const text = 'x'.repeat(2000); + fetchSpy.mockResolvedValueOnce(new Response(text, { status: 400 })); + + const error = await client().request(URL_1, { method: 'PUT' }).catch((e: unknown) => e); + + expect((error as Error).message).toBe(`HTTP 400: ${'x'.repeat(500)}…`); + expect((error as HttpError).body).toBe(text); + }); + + it('should keep a long ARM error message and its details in full', async () => { + const longMessage = 'y'.repeat(2000); + fetchSpy.mockResolvedValueOnce( + json(400, { error: { code: 'Bad', message: longMessage, details: [{ message: 'z'.repeat(800) }] } }) + ); + + const error = await client().request(URL_1, { method: 'PUT' }).catch((e: unknown) => e); + + expect((error as Error).message).toBe(`HTTP 400: [Bad] ${longMessage}: ${'z'.repeat(800)}`); + }); + + it('should retry a 4xx the caller marks as retryable', async () => { + fetchSpy + .mockResolvedValueOnce(json(409, { error: { code: 'Busy' } })) + .mockResolvedValueOnce(json(200, { ok: true })); + + const response = await client().request(URL_1, { method: 'PUT' }, { + isRetryableError: (error) => error.code === 'Busy', + }); + + expect(response.status).toBe(200); + expect(delays).toEqual([1000]); + }); + + it('should return a 404 for GET by default and throw it for other methods', async () => { + fetchSpy.mockResolvedValueOnce(empty(404)).mockResolvedValueOnce(empty(404)); + const http = client(); + + await expect(http.request(URL_1)).resolves.toMatchObject({ status: 404 }); + await expect(http.request(URL_1, { method: 'DELETE' })).rejects.toMatchObject({ status: 404 }); + }); + + it('should return a 404 for the configured passthrough methods', async () => { + fetchSpy.mockResolvedValueOnce(empty(404)); + + await expect( + client().request(URL_1, { method: 'DELETE' }, { passthrough404Methods: ['GET', 'DELETE'] }), + ).resolves.toMatchObject({ status: 404 }); + }); + + it('should return allowed non-OK statuses', async () => { + fetchSpy.mockResolvedValueOnce(empty(406)); + + await expect( + client().request(URL_1, {}, { allowedNonOkStatuses: [406] }), + ).resolves.toMatchObject({ status: 406 }); + }); + + it('should retry 5xx with back-off, then throw the last one', async () => { + fetchSpy.mockImplementation(async () => new Response('down', { status: 503 })); + + const error = await client().request(URL_1).catch((e: unknown) => e); + + expect(error).toMatchObject({ status: 503 }); + expect(fetchSpy).toHaveBeenCalledTimes(4); + expect(delays).toEqual([1000, 2000, 4000]); + }); + + it('should add up to 30% jitter to the back-off', async () => { + vi.spyOn(Math, 'random').mockReturnValue(1); + fetchSpy.mockImplementation(async () => new Response('down', { status: 503 })); + + await client().request(URL_1).catch(() => undefined); + + expect(delays).toEqual([1300, 2600, 5200]); + }); + + it('should return a 5xx immediately with noRetryOn5xx', async () => { + fetchSpy.mockResolvedValueOnce(new Response('wiki', { status: 500 })); + + await expect(client().request(URL_1, {}, { noRetryOn5xx: true })).resolves.toMatchObject({ + status: 500, + }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('should retry a network error', async () => { + fetchSpy + .mockRejectedValueOnce(new TypeError('fetch failed')) + .mockResolvedValueOnce(json(200, {})); + + await expect(client().request(URL_1)).resolves.toMatchObject({ status: 200 }); + expect(delays).toEqual([1000]); + }); + + it('should wait for numeric and HTTP-date Retry-After on 429', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + fetchSpy + .mockResolvedValueOnce(empty(429, { 'Retry-After': '4' })) + .mockResolvedValueOnce(empty(429, { 'Retry-After': 'Thu, 01 Jan 2026 00:00:09 GMT' })) + .mockResolvedValueOnce(json(200, {})); + + await client().request(URL_1); + + expect(delays).toEqual([4000, 9000]); + }); + + it('should retry at once for a Retry-After of 0 or an HTTP-date in the past, as the Azure SDK does', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + fetchSpy + .mockResolvedValueOnce(empty(429, { 'Retry-After': '0' })) + .mockResolvedValueOnce(empty(429, { 'Retry-After': 'Wed, 31 Dec 2025 23:59:00 GMT' })) + .mockResolvedValueOnce(json(200, {})); + + await client().request(URL_1); + + expect(delays).toEqual([0, 0]); + }); + + it('should read retry-after-ms, then x-ms-retry-after-ms, before Retry-After', async () => { + fetchSpy + .mockResolvedValueOnce(empty(429, { 'retry-after-ms': '1500', 'x-ms-retry-after-ms': '2500', 'Retry-After': '9' })) + .mockResolvedValueOnce(empty(429, { 'retry-after-ms': 'soon', 'x-ms-retry-after-ms': '2500', 'Retry-After': '9' })) + .mockResolvedValueOnce(empty(429, { 'x-ms-retry-after-ms': '', 'Retry-After': '9' })) + .mockResolvedValueOnce(json(200, {})); + + await client().request(URL_1); + + expect(delays).toEqual([1500, 2500, 9000]); + }); + + it('should wait for the Retry-After of a 503 and back off without one', async () => { + fetchSpy + .mockResolvedValueOnce(empty(503, { 'Retry-After': '7' })) + .mockResolvedValueOnce(empty(503, { 'x-ms-retry-after-ms': '0' })) + .mockResolvedValueOnce(empty(503)) + .mockResolvedValueOnce(json(200, {})); + + await client().request(URL_1); + + expect(delays).toEqual([7000, 0, 4000]); + }); + + it('should ignore Retry-After on a 5xx other than 503', async () => { + fetchSpy + .mockResolvedValueOnce(empty(500, { 'Retry-After': '7' })) + .mockResolvedValueOnce(json(200, {})); + + await client().request(URL_1); + + expect(delays).toEqual([1000]); + }); + + it('should wait 1, 2 and 4 seconds for a missing or unparsable Retry-After, then throw the 429', async () => { + fetchSpy + .mockResolvedValueOnce(empty(429)) + .mockResolvedValueOnce(empty(429, { 'Retry-After': 'later' })) + .mockResolvedValueOnce(empty(429)) + .mockResolvedValueOnce(json(429, { error: { code: 'TooManyRequests' } }, { 'Retry-After': '60' })); + + const error = await client().request(URL_1).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(HttpError); + expect(error).toMatchObject({ status: 429, code: 'TooManyRequests' }); + expect(delays).toEqual([1000, 2000, 4000]); + }); + + it.each([429, 503])('should cancel the body of a %i response before retrying it', async (status) => { + const cancel = vi.fn(); + fetchSpy + .mockResolvedValueOnce(new Response(new ReadableStream({ cancel }), { status })) + .mockResolvedValueOnce(json(200, {})); + + await client().request(URL_1); + + expect(cancel).toHaveBeenCalledTimes(1); + }); + + it('should still retry when cancelling the body fails', async () => { + const cancel = vi.fn(() => { + throw new Error('locked'); + }); + fetchSpy + .mockResolvedValueOnce(new Response(new ReadableStream({ cancel }), { status: 503 })) + .mockResolvedValueOnce(json(200, {})); + + await expect(client().request(URL_1)).resolves.toMatchObject({ status: 200 }); + }); + + it('should warn with the target and attempt outside a tracking scope', async () => { + fetchSpy + .mockResolvedValueOnce(new Response('busy', { status: 503 })) + .mockResolvedValueOnce(json(200, {})); + + vi.spyOn(Math, 'random').mockReturnValue(2 / 3); + await client().request( + `${ARM}/subscriptions/s/resourceGroups/r/providers/Microsoft.ApiCenter/services/a/apis/x?api-version=1`, + ); + + expect(logger.warn).toHaveBeenCalledWith( + 'Server error 503 on GET apis/x (attempt 1/4), retrying in 1.2s', + ); + }); + + it('should count retries and log them at debug inside a tracking scope', async () => { + fetchSpy + .mockResolvedValueOnce(empty(429, { 'Retry-After': '1' })) + .mockResolvedValueOnce(json(200, {})); + + const { retries } = await trackRetries(() => + client().request(URL_1), + ); + + expect(retries.total).toBe(1); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('Rate limited (429)')); + }); +}); + +describe('paginateArm', () => { + async function collect(iterable: AsyncIterable>): Promise { + const items: unknown[] = []; + for await (const item of iterable) items.push(item); + return items; + } + + it('should follow nextLink across pages', async () => { + const page2 = `${URL_1}&$skipToken=2`; + fetchSpy + .mockResolvedValueOnce(json(200, { value: [{ n: 1 }], nextLink: page2 })) + .mockResolvedValueOnce(json(200, { value: [{ n: 2 }] })); + + await expect(collect(paginateArm(client(), URL_1))).resolves.toEqual([{ n: 1 }, { n: 2 }]); + expect(fetchSpy.mock.calls.map((c) => c[0])).toEqual([URL_1, page2]); + }); + + it('should end quietly on 404 and call onNotFound', async () => { + const onNotFound = vi.fn(); + fetchSpy.mockResolvedValueOnce(empty(404)); + + await expect(collect(paginateArm(client(), URL_1, { onNotFound }))).resolves.toEqual([]); + expect(onNotFound).toHaveBeenCalledTimes(1); + }); + + it('should cancel the body of a 404 that ends the listing', async () => { + const cancel = vi.fn(); + fetchSpy.mockResolvedValueOnce(unread(404, cancel)); + + await expect(collect(paginateArm(client(), URL_1))).resolves.toEqual([]); + expect(cancel).toHaveBeenCalledTimes(1); + }); + + it('should end quietly when shouldStopOnError returns true', async () => { + fetchSpy.mockResolvedValueOnce(json(400, { error: { code: 'MethodNotAllowedInPricingTier' } })); + + await expect( + collect( + paginateArm(client(), URL_1, { + shouldStopOnError: (error) => (error as HttpError).code === 'MethodNotAllowedInPricingTier', + }), + ), + ).resolves.toEqual([]); + }); + + it('should rethrow when shouldStopOnError returns false', async () => { + fetchSpy.mockResolvedValueOnce(json(403, { error: { code: 'AuthorizationFailed' } })); + + await expect( + collect(paginateArm(client(), URL_1, { shouldStopOnError: () => false })), + ).rejects.toMatchObject({ status: 403 }); + }); + + it('should pass request options to every page request', async () => { + fetchSpy.mockResolvedValueOnce(json(409, { error: { code: 'Busy' } })).mockResolvedValueOnce( + json(200, { value: [] }), + ); + + await collect( + paginateArm(client(), URL_1, { + requestOptions: { isRetryableError: (e) => e.code === 'Busy' }, + }), + ); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it.each([ + 'https://attacker.example.com/list?page=2', + 'http://management.azure.com/list?page=2', + ])('should refuse to follow nextLink %s', async (nextLink) => { + fetchSpy.mockResolvedValueOnce(json(200, { value: [{ n: 1 }], nextLink })); + + await expect(collect(paginateArm(client(), URL_1))).rejects.toThrow( + /Refusing to follow nextLink/, + ); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('should follow a nextLink only on the client\'s own cloud', async () => { + const usgovList = 'https://management.usgovcloudapi.net/subscriptions/s/list'; + const usgov = new ArmHttpClient({ + authScope: 'https://management.usgovcloudapi.net/.default', + credential: cred, + }); + fetchSpy + .mockResolvedValueOnce(json(200, { value: [{ n: 1 }], nextLink: `${usgovList}?page=2` })) + .mockResolvedValueOnce(json(200, { value: [{ n: 2 }], nextLink: `${ARM}/list?page=3` })); + + await expect(collect(paginateArm(usgov, usgovList))).rejects.toThrow(/Refusing to follow nextLink/); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); +}); + +describe('extractAsyncOperation', () => { + const armHosts = ['management.azure.com']; + + it('should prefer Azure-AsyncOperation, then Operation-Location, then Location and name the header', () => { + const a = `${ARM}/a`; + const o = `${ARM}/o`; + const l = `${ARM}/l`; + expect( + extractAsyncOperation( + empty(202, { 'Azure-AsyncOperation': a, 'Operation-Location': o, Location: l }), + { armHosts }, + ), + ).toEqual({ url: a, header: 'Azure-AsyncOperation' }); + expect(extractAsyncOperation(empty(202, { 'Operation-Location': o, Location: l }), { armHosts })).toEqual({ + url: o, + header: 'Operation-Location', + }); + expect(extractAsyncOperation(empty(202, { Location: l }), { armHosts })).toEqual({ url: l, header: 'Location' }); + expect(extractAsyncOperation(empty(202), { armHosts })).toBeUndefined(); + }); + + it('should fall back to a valid Location when Azure-AsyncOperation is rejected', () => { + const response = empty(202, { + 'Azure-AsyncOperation': 'https://attacker.example.com/op?sig=secret', + Location: `${ARM}/l`, + }); + + expect(extractAsyncOperation(response, { armHosts })).toEqual({ url: `${ARM}/l`, header: 'Location' }); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Ignoring')); + }); + + it('should fall back to Operation-Location when Azure-AsyncOperation is empty', () => { + const response = empty(202, { 'Azure-AsyncOperation': '', 'Operation-Location': `${ARM}/o` }); + + expect(extractAsyncOperation(response, { armHosts })).toEqual({ url: `${ARM}/o`, header: 'Operation-Location' }); + }); + + it('should return undefined with a warning per header when every header is rejected', () => { + const response = empty(202, { + 'Azure-AsyncOperation': 'not a url', + 'Operation-Location': 'http://management.azure.com/o?api-version=1', + Location: 'https://management.usgovcloudapi.net/l', + }); + + expect(extractAsyncOperation(response, { armHosts })).toBeUndefined(); + expect(logger.warn).toHaveBeenCalledTimes(3); + }); + + it('should not fall back to Location when includeLocation is false', () => { + const response = empty(200, { 'Azure-AsyncOperation': 'not a url', Location: `${ARM}/l` }); + + expect(extractAsyncOperation(response, { includeLocation: false, armHosts })).toBeUndefined(); + }); +}); + +describe('readArmState', () => { + it.each([ + ['nothing for null', null, true, undefined], + ['nothing for a string', 'Succeeded', true, undefined], + ])('should read %s', (_label, body, includeStatus, expected) => { + expect(readArmState(body, { includeStatus })).toBe(expected); + }); +}); + +describe('pollArmOperation', () => { + const options = { timeoutMs: 60_000, intervalMs: 5000 }; + /** A Location polling URL, which may return the resource rather than a status. */ + const LOCATION_OPERATION = { url: OPERATION_URL, header: 'Location' } as const; + /** An async-operation status endpoint, whose body reports the operation's `status`. */ + const STATUS_OPERATION = { url: OPERATION_URL, header: 'Azure-AsyncOperation' } as const; + + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + }); + + /** Client whose delays advance the (fake) clock. */ + function clockClient(): ArmHttpClient { + return new ArmHttpClient({ + credential: cred, + delay: async (ms) => { + delays.push(ms); + vi.setSystemTime(Date.now() + ms); + }, + }); + } + + it('should poll immediately by default and resolve on Succeeded', async () => { + fetchSpy.mockResolvedValueOnce(json(200, { status: 'Succeeded' })); + + await pollArmOperation(clockClient(), STATUS_OPERATION, 'thing', options); + + expect(delays).toEqual([]); + expect(fetchSpy.mock.calls[0][0]).toBe(OPERATION_URL); + expect(headersOf(0).get('Authorization')).toBe('Bearer fake-token'); + }); + + it('should wait initialDelayMs before the first poll', async () => { + fetchSpy.mockResolvedValueOnce(json(200, { status: 'Succeeded' })); + + await pollArmOperation(clockClient(), LOCATION_OPERATION, 'thing', { ...options, initialDelayMs: 5000 }); + + expect(delays).toEqual([5000]); + }); + + it('should keep the last positive Retry-After until another replaces it, as @azure/core-lro does', async () => { + fetchSpy + .mockResolvedValueOnce(json(200, { status: 'InProgress' }, { 'Retry-After': '2' })) + .mockResolvedValueOnce(json(200, { status: 'Running' }, { 'Retry-After': 'Thu, 01 Jan 2026 00:00:12 GMT' })) + .mockResolvedValueOnce(json(200, { status: 'InProgress' }, { 'Retry-After': 'soon' })) + .mockResolvedValueOnce(json(200, { status: 'InProgress' })) + .mockResolvedValueOnce(json(200, { status: 'Succeeded' })); + + await pollArmOperation(clockClient(), STATUS_OPERATION, 'thing', options); + + expect(delays).toEqual([2000, 10_000, 10_000, 10_000]); + }); + + it('should keep the current wait for a Retry-After of 0 or a past date after a positive one', async () => { + fetchSpy + .mockResolvedValueOnce(json(200, { status: 'InProgress' }, { 'Retry-After': '8' })) + .mockResolvedValueOnce(json(200, { status: 'InProgress' }, { 'Retry-After': '0' })) + .mockResolvedValueOnce(json(200, { status: 'InProgress' }, { 'Retry-After': 'Wed, 31 Dec 2025 23:00:00 GMT' })) + .mockResolvedValueOnce(json(200, { status: 'InProgress' }, { 'Retry-After': '3' })) + .mockResolvedValueOnce(json(200, { status: 'Succeeded' })); + + await pollArmOperation(clockClient(), STATUS_OPERATION, 'thing', options); + + expect(delays).toEqual([8000, 8000, 8000, 3000]); + }); + + it('should use the interval for a Retry-After of 0 between polls', async () => { + fetchSpy + .mockResolvedValueOnce(json(200, { status: 'InProgress' }, { 'Retry-After': '0' })) + .mockResolvedValueOnce(json(200, { status: 'Succeeded' })); + + await pollArmOperation(clockClient(), STATUS_OPERATION, 'thing', options); + + expect(delays).toEqual([5000]); + }); + + it('should accept a status in upper case', async () => { + fetchSpy.mockResolvedValueOnce(json(200, { status: 'SUCCEEDED' })); + + await expect( + pollArmOperation(clockClient(), STATUS_OPERATION, 'thing', options), + ).resolves.toBeUndefined(); + }); + + it.each(['Failed', 'canceled', 'Cancelled'])('should throw with code and message on %s', async (status) => { + fetchSpy.mockResolvedValueOnce( + json(200, { status, error: { code: 'InvalidSpec', message: 'Spec is invalid' } }), + ); + + await expect(pollArmOperation(clockClient(), STATUS_OPERATION, 'thing', options)).rejects.toThrow( + `Async operation ${status.toLowerCase()} for thing: [InvalidSpec] Spec is invalid`, + ); + }); + + it('should fall back to UnknownError and the body when the error code and message are not strings', async () => { + fetchSpy.mockResolvedValueOnce(json(200, { status: 'Failed', error: { code: 42, message: { text: 'x' } } })); + + await expect(pollArmOperation(clockClient(), STATUS_OPERATION, 'thing', options)).rejects.toThrow( + 'Async operation failed for thing: [UnknownError] {"status":"Failed","error":{"code":42,"message":{"text":"x"}}}', + ); + }); + + it('should read properties.provisioningState when there is no status', async () => { + fetchSpy + .mockResolvedValueOnce(json(200, { properties: { provisioningState: 'Deleting' } })) + .mockResolvedValueOnce(json(200, { properties: { provisioningState: 'Failed' } })); + + await expect(pollArmOperation(clockClient(), LOCATION_OPERATION, 'thing', options)).rejects.toThrow( + /Async operation failed for thing: \[UnknownError\] .*provisioningState/, + ); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it('should keep polling a 202 and treat a 2xx without a state as success', async () => { + fetchSpy + .mockResolvedValueOnce(json(202, { status: 'Succeeded' })) + .mockResolvedValueOnce(empty(202)) + .mockResolvedValueOnce(json(200, { name: 'resource' })); + + await pollArmOperation(clockClient(), LOCATION_OPERATION, 'thing', options); + + expect(fetchSpy).toHaveBeenCalledTimes(3); + }); + + it.each([ + ['an empty 200', () => empty(200)], + ['a 204', () => empty(204)], + ['a non-JSON 200', () => new Response('done', { status: 200 })], + ])('should treat %s as success', async (_label, response) => { + fetchSpy.mockResolvedValueOnce(response()); + + await expect( + pollArmOperation(clockClient(), LOCATION_OPERATION, 'thing', options), + ).resolves.toBeUndefined(); + }); + + it.each([ + ['null', 'null'], + ['an array', '[]'], + ])('should treat a 2xx body of JSON %s as complete without a status', async (_label, text) => { + fetchSpy.mockResolvedValueOnce(new Response(text, { status: 200 })); + + await expect( + pollArmOperation(clockClient(), LOCATION_OPERATION, 'thing', options), + ).resolves.toBeUndefined(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + describe('for a Location URL', () => { + it.each([ + ['without a provisioningState', { name: 'resource', status: 'Active' }], + ['with a Succeeded provisioningState', { name: 'resource', status: 'Active', properties: { provisioningState: 'Succeeded' } }], + ['whose status is Failed but provisioningState Succeeded', { status: 'Failed', properties: { provisioningState: 'Succeeded' } }], + ])('should resolve on a resource %s, ignoring its own top-level status', async (_label, body) => { + fetchSpy.mockResolvedValueOnce(json(200, body)); + + await expect( + pollArmOperation(clockClient(), LOCATION_OPERATION, 'thing', options), + ).resolves.toBeUndefined(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('should keep polling a resource whose provisioningState is not terminal, whatever its status', async () => { + fetchSpy + .mockResolvedValueOnce(json(200, { status: 'Succeeded', properties: { provisioningState: 'Updating' } })) + .mockResolvedValueOnce(json(200, { status: 'Active', properties: { provisioningState: 'Succeeded' } })); + + await pollArmOperation(clockClient(), LOCATION_OPERATION, 'thing', options); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it('should throw on an operation-status body that reports Canceled', async () => { + fetchSpy.mockResolvedValueOnce( + json(200, { status: 'Canceled', error: { code: 'ProvisioningFailed', message: 'boom' } }), + ); + + await expect( + pollArmOperation(clockClient(), LOCATION_OPERATION, 'thing', options), + ).rejects.toThrow('Async operation canceled for thing: [ProvisioningFailed] boom'); + }); + + it('should throw on a Failed operation-status body with properties and an error object', async () => { + fetchSpy.mockResolvedValueOnce( + json(200, { status: 'Failed', properties: { name: 'x' }, error: { code: 'Conflict', message: 'boom' } }), + ); + + await expect( + pollArmOperation(clockClient(), LOCATION_OPERATION, 'thing', options), + ).rejects.toThrow('Async operation failed for thing: [Conflict] boom'); + }); + + it("should include the error details in a failed operation's message", async () => { + fetchSpy.mockResolvedValueOnce(json(200, { + status: 'Failed', + error: { code: 'ValidationError', message: 'One or more fields contain incorrect values:', details: [{ target: 'value', message: 'Too long' }] }, + })); + + await expect( + pollArmOperation(clockClient(), LOCATION_OPERATION, 'thing', options), + ).rejects.toThrow('Async operation failed for thing: [ValidationError] One or more fields contain incorrect values: value: Too long'); + }); + + it('should resolve on a resource with properties whose own top-level status is Failed', async () => { + fetchSpy.mockResolvedValueOnce(json(200, { status: 'Failed', properties: { displayName: 'x' } })); + + await expect( + pollArmOperation(clockClient(), LOCATION_OPERATION, 'thing', options), + ).resolves.toBeUndefined(); + }); + + it('should resolve on an operation-status body that reports Succeeded', async () => { + fetchSpy.mockResolvedValueOnce(json(200, { status: 'Succeeded' })); + + await expect( + pollArmOperation(clockClient(), LOCATION_OPERATION, 'thing', options), + ).resolves.toBeUndefined(); + }); + }); + + describe('for an Operation-Location status endpoint', () => { + const statusOperation = { url: OPERATION_URL, header: 'Operation-Location' as const }; + + it('should read the operation status before provisioningState', async () => { + fetchSpy + .mockResolvedValueOnce(json(200, { status: 'InProgress', properties: { provisioningState: 'Succeeded' } })) + .mockResolvedValueOnce(json(200, { status: 'Failed', properties: { provisioningState: 'Succeeded' } })); + + await expect(pollArmOperation(clockClient(), statusOperation, 'thing', options)).rejects.toThrow( + /^Async operation failed for thing: \[UnknownError\]/, + ); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it('should keep polling a JSON null status body until it reports a status', async () => { + fetchSpy + .mockResolvedValueOnce(new Response('null', { status: 200 })) + .mockResolvedValueOnce(json(200, { status: 'Succeeded' })); + + await pollArmOperation(clockClient(), statusOperation, 'thing', options); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it('should keep polling a JSON body without a status until it reports one', async () => { + fetchSpy + .mockResolvedValueOnce(json(200, { id: 'op-1', name: 'op-1' })) + .mockResolvedValueOnce(json(200, { status: 'Succeeded' })); + + await pollArmOperation(clockClient(), statusOperation, 'thing', options); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(delays).toEqual([5000]); + }); + + it('should time out when the status body never reports a status', async () => { + fetchSpy.mockImplementation(async () => json(200, { id: 'op-1' })); + + await expect( + pollArmOperation(clockClient(), statusOperation, 'thing', options), + ).rejects.toThrow('Async operation polling timed out after 60s for thing'); + }); + + it('should treat an empty 200 as success', async () => { + fetchSpy.mockResolvedValueOnce(empty(200)); + + await expect( + pollArmOperation(clockClient(), statusOperation, 'thing', options), + ).resolves.toBeUndefined(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + }); + + it('should treat a 404 as success when treatMissingAsSuccess', async () => { + fetchSpy.mockResolvedValueOnce(json(404, { error: { code: 'NotFound' } })); + + await expect( + pollArmOperation(clockClient(), LOCATION_OPERATION, 'thing', { ...options, treatMissingAsSuccess: true }), + ).resolves.toBeUndefined(); + }); + + it('should cancel the body of a 404 poll response that counts as success', async () => { + const cancel = vi.fn(); + fetchSpy.mockResolvedValueOnce(unread(404, cancel)); + + await pollArmOperation(clockClient(), LOCATION_OPERATION, 'thing', { ...options, treatMissingAsSuccess: true }); + + expect(cancel).toHaveBeenCalledTimes(1); + }); + + it('should cancel the body of a 202 poll response before polling again', async () => { + const cancel = vi.fn(); + fetchSpy + .mockResolvedValueOnce(unread(202, cancel)) + .mockResolvedValueOnce(json(200, { status: 'Succeeded' })); + + await pollArmOperation(clockClient(), LOCATION_OPERATION, 'thing', options); + + expect(cancel).toHaveBeenCalledTimes(1); + }); + + it('should fail on a 404 otherwise instead of polling until the timeout', async () => { + fetchSpy.mockResolvedValueOnce(json(404, { error: { code: 'NotFound' } })); + + await expect(pollArmOperation(clockClient(), LOCATION_OPERATION, 'thing', options)).rejects.toThrow( + 'Async operation for thing not found (404).', + ); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('should time out after timeoutMs', async () => { + fetchSpy.mockImplementation(async () => json(200, { status: 'InProgress' })); + + await expect(pollArmOperation(clockClient(), STATUS_OPERATION, 'thing', options)).rejects.toThrow( + 'Async operation polling timed out after 60s for thing', + ); + // Polls at 0s, 5s, ..., 60s: the poll after the last wait still runs. + expect(fetchSpy).toHaveBeenCalledTimes(13); + }); +}); + +describe('waitUntilGone', () => { + const PRESENT = { name: 'resource', properties: {} }; + + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + }); + + /** Wait that records each delay and advances the (fake) clock. */ + async function clockDelay(ms: number): Promise { + delays.push(ms); + vi.setSystemTime(Date.now() + ms); + } + + /** Probe that returns each value in turn, then undefined (gone). */ + function probeOf(...values: unknown[]): ReturnType { + const probe = vi.fn(); + for (const value of values) probe.mockResolvedValueOnce(value); + return probe.mockResolvedValue(undefined); + } + + it('should wait the same interval before each probe until the resource is gone', async () => { + const probe = probeOf(PRESENT, PRESENT, PRESENT); + + await waitUntilGone(probe, 'thing', { timeoutMs: 600_000, intervalMs: 2000, delay: clockDelay }); + + expect(probe).toHaveBeenCalledTimes(4); + expect(delays).toEqual([2000, 2000, 2000, 2000]); + }); + + it('should accept a presence-only probe that returns true while the resource exists', async () => { + const probe = vi.fn().mockResolvedValueOnce(true).mockResolvedValueOnce(true).mockResolvedValue(false); + + await waitUntilGone(probe, 'thing', { timeoutMs: 600_000, intervalMs: 2000, delay: clockDelay }); + + expect(probe).toHaveBeenCalledTimes(3); + }); + + it.each(['Failed', 'Canceled', 'Succeeded', 'no state'])('should keep waiting while the resource reports %s', async (state) => { + const resource = state === 'no state' ? PRESENT : { properties: { provisioningState: state } }; + const probe = probeOf(resource); + + await expect( + waitUntilGone(probe, 'thing', { timeoutMs: 60_000, intervalMs: 5000, delay: clockDelay }), + ).resolves.toBeUndefined(); + + expect(probe).toHaveBeenCalledTimes(2); + }); + + it('should probe at the deadline, shortening the last wait, then throw', async () => { + const probe = vi.fn().mockResolvedValue(PRESENT); + const start = Date.now(); + + await expect( + waitUntilGone(probe, 'apis/x', { timeoutMs: 12_000, intervalMs: 5000, delay: clockDelay }), + ).rejects.toThrow('apis/x was not deleted within 12.0s: the DELETE was accepted but the resource is still present.'); + + expect(delays).toEqual([5000, 5000, 2000]); + expect(probe).toHaveBeenCalledTimes(3); + expect(Date.now() - start).toBe(12_000); + }); + + it('should throw a probe error unchanged', async () => { + const error = new HttpError(403, 'HTTP 403: denied'); + const probe = vi.fn().mockRejectedValue(error); + + await expect( + waitUntilGone(probe, 'thing', { timeoutMs: 60_000, intervalMs: 5000, delay: clockDelay }), + ).rejects.toBe(error); + expect(probe).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/clients/preflight.test.ts b/tests/unit/clients/preflight.test.ts new file mode 100644 index 00000000..fa236cd6 --- /dev/null +++ b/tests/unit/clients/preflight.test.ts @@ -0,0 +1,211 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +/** + * Unit tests for the shared publish pre-flight check: the resource group and + * service GETs, their URLs, and the messages for missing resources and + * 401/403 responses. `get` is a stub, so no HTTP client is involved. + */ + +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; +import { + PreFlightContext, + PreFlightGet, + PreFlightServiceKind, + validateServicePreFlight, +} from '../../../src/clients/preflight.js'; +import { HttpError } from '../../../src/clients/arm-http-client.js'; +import { logger } from '../../../src/lib/logger.js'; + +const context: PreFlightContext = { + subscriptionId: 'sub-1', + resourceGroup: 'rg-1', + serviceName: 'svc-1', + apiVersion: '2024-01-01', + baseUrl: 'https://management.azure.com/subscriptions/sub-1/resourceGroups/rg-1/providers/Test.Provider/services/svc-1', +}; + +const kind: PreFlightServiceKind = { + label: 'Test service', + instance: 'test instance', + roleTarget: 'the test resource', +}; + +const RG_URL = 'https://management.azure.com/subscriptions/sub-1/resourceGroups/rg-1?api-version=2021-04-01'; +const SERVICE_URL = `${context.baseUrl}?api-version=${context.apiVersion}`; + +/** A GET outcome: a returned response status, or a thrown error. */ +type Outcome = number | Error; + +function armError(status: number): HttpError { + return new HttpError(status, `HTTP ${status}: {}`, 'SomeCode'); +} + +/** + * Stub `get` that answers the resource group and service URLs with the given + * outcomes. Like the clients' request wrappers, it returns a 404 (or 2xx) + * response and throws an HttpError for other non-2xx statuses. + */ +function stubGet(rg: Outcome, service: Outcome): Mock { + return vi.fn(async (url: string) => { + const outcome = url === RG_URL ? rg : url === SERVICE_URL ? service : new Error(`Unexpected URL: ${url}`); + if (outcome instanceof Error) { + throw outcome; + } + return new Response('{}', { status: outcome }); + }); +} + +describe('validateServicePreFlight', () => { + beforeEach(() => { + vi.spyOn(logger, 'debug').mockImplementation(() => undefined); + vi.spyOn(logger, 'warn').mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should check the resource group and then the service', async () => { + const get = stubGet(200, 200); + + await expect(validateServicePreFlight(context, kind, get)).resolves.toBeUndefined(); + + expect(get.mock.calls.map(([url]) => url)).toEqual([RG_URL, SERVICE_URL]); + }); + + it('should build both URLs from the base URL origin with encoding and api-versions', async () => { + const sovereign: PreFlightContext = { + subscriptionId: 'sub/1', + resourceGroup: 'rg with spaces', + serviceName: 'svc-1', + apiVersion: '2024-06-01-preview', + baseUrl: + 'https://management.usgovcloudapi.net/subscriptions/sub%2F1/resourceGroups/rg%20with%20spaces' + + '/providers/Test.Provider/services/svc-1', + }; + const get = vi.fn(async () => new Response('{}', { status: 200 })); + + await validateServicePreFlight(sovereign, kind, get); + + expect(get.mock.calls.map(([url]) => url)).toEqual([ + 'https://management.usgovcloudapi.net/subscriptions/sub%2F1/resourceGroups/rg%20with%20spaces?api-version=2021-04-01', + `${sovereign.baseUrl}?api-version=2024-06-01-preview`, + ]); + }); + + describe('resource group read forbidden (403)', () => { + it('should pass when the service exists, logging at debug and not warning', async () => { + const get = stubGet(armError(403), 200); + + await expect(validateServicePreFlight(context, kind, get)).resolves.toBeUndefined(); + + expect(get).toHaveBeenCalledTimes(2); + expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining("No permission to read resource group 'rg-1'")); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('should name the subscription and resource group as possibly wrong when the service is not found', async () => { + const get = stubGet(armError(403), 404); + + await expect(validateServicePreFlight(context, kind, get)).rejects.toThrow( + "Test service 'svc-1' not found in resource group 'rg-1' of subscription 'sub-1'. " + + 'Ensure the subscription, resource group and Test service name are correct ' + + 'and that the test instance exists before publishing.', + ); + }); + }); + + it.each([ + ['resource group', 404, 200], + ['service', 200, 404], + ])('should cancel the unread body of a 404 for the %s', async (_label, rgStatus, serviceStatus) => { + const cancel = vi.fn(); + const get = vi.fn(async (url: string) => + (url === RG_URL ? rgStatus : serviceStatus) === 404 + ? new Response(new ReadableStream({ cancel }), { status: 404 }) + : new Response('{}', { status: 200 })); + + await expect(validateServicePreFlight(context, kind, get)).rejects.toThrow(/not found/); + expect(cancel).toHaveBeenCalledTimes(1); + }); + + it('should cancel the unread bodies of the resource group and service 200 responses', async () => { + const cancel = vi.fn(); + const get = vi.fn(async () => new Response(new ReadableStream({ cancel }), { status: 200 })); + + await expect(validateServicePreFlight(context, kind, get)).resolves.toBeUndefined(); + expect(cancel).toHaveBeenCalledTimes(2); + }); + + it('should report a missing resource group without checking the service', async () => { + const get = stubGet(404, 200); + + await expect(validateServicePreFlight(context, kind, get)).rejects.toThrow( + "Resource group 'rg-1' not found in subscription 'sub-1'. Ensure the resource group exists before publishing.", + ); + expect(get).toHaveBeenCalledTimes(1); + }); + + it('should report an authentication failure on the resource group without checking the service', async () => { + const cause = armError(401); + const get = stubGet(cause, 200); + + const error = await validateServicePreFlight(context, kind, get).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe( + "Authentication failed when checking resource group 'rg-1'. " + + 'Verify that credentials are valid and the correct tenant/subscription is targeted.', + ); + expect((error as Error).cause).toBe(cause); + expect(get).toHaveBeenCalledTimes(1); + }); + + it('should report a missing service when the resource group exists', async () => { + const get = stubGet(200, 404); + + await expect(validateServicePreFlight(context, kind, get)).rejects.toThrow( + "Test service 'svc-1' not found in resource group 'rg-1'. Ensure the test instance exists before publishing.", + ); + }); + + it('should name the role target when the service read is forbidden', async () => { + const cause = armError(403); + const get = stubGet(200, cause); + + const error = await validateServicePreFlight(context, kind, get).catch((e: unknown) => e); + + expect((error as Error).message).toBe( + "Insufficient permissions to read Test service 'svc-1'. " + + 'Ensure the service principal has at least Reader role on the test resource.', + ); + expect((error as Error).cause).toBe(cause); + }); + + it('should report an authentication failure on the service', async () => { + const get = stubGet(200, armError(401)); + + await expect(validateServicePreFlight(context, kind, get)).rejects.toThrow( + "Authentication failed when checking Test service 'svc-1'.", + ); + }); + + it.each([ + ['an HttpError with another status', () => armError(400)], + ['a non-ARM error', () => new TypeError('fetch failed')], + ['a non-ARM error with a status property', () => Object.assign(new Error('forbidden'), { status: 403 })], + ])('should rethrow %s from the resource group read unchanged', async (_label, makeError) => { + const thrown = makeError(); + const get = stubGet(thrown, 200); + + await expect(validateServicePreFlight(context, kind, get)).rejects.toBe(thrown); + expect(get).toHaveBeenCalledTimes(1); + }); + + it('should rethrow a non-ARM error from the service read unchanged', async () => { + const thrown = new TypeError('fetch failed'); + const get = stubGet(200, thrown); + + await expect(validateServicePreFlight(context, kind, get)).rejects.toBe(thrown); + }); +}); diff --git a/tests/unit/lib/cloud-config.test.ts b/tests/unit/lib/cloud-config.test.ts index 731624da..7facfe80 100644 --- a/tests/unit/lib/cloud-config.test.ts +++ b/tests/unit/lib/cloud-config.test.ts @@ -9,6 +9,7 @@ import { getCloudConfig, buildArmBaseUrl, getArmHosts, + getArmHostsForAuthScope, } from '../../../src/lib/cloud-config.js'; describe('getCloudConfig', () => { @@ -149,6 +150,19 @@ describe('buildArmBaseUrl', () => { }); }); +describe('getArmHostsForAuthScope', () => { + it.each([ + ['https://management.azure.com/.default', 'management.azure.com'], + ['https://MANAGEMENT.usgovcloudapi.net/.default', 'management.usgovcloudapi.net'], + ])('should return only the ARM host of the cloud of %s', (authScope, host) => { + expect(getArmHostsForAuthScope(authScope)).toEqual([host]); + }); + + it('should return every known ARM host for a scope of no known cloud', () => { + expect(getArmHostsForAuthScope('https://arm.example.test/.default')).toEqual(getArmHosts()); + }); +}); + describe('getArmHosts', () => { it('should return the ARM host name of every known cloud', () => { expect(getArmHosts()).toEqual([ diff --git a/tests/unit/lib/error-message.test.ts b/tests/unit/lib/error-message.test.ts new file mode 100644 index 00000000..9c2e7e0c --- /dev/null +++ b/tests/unit/lib/error-message.test.ts @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +/** + * Unit tests for errorMessage + */ + +import { describe, it, expect } from 'vitest'; +import { errorMessage } from '../../../src/lib/error-message.js'; + +describe('errorMessage', () => { + it('should return the message of an Error', () => { + expect(errorMessage(new TypeError('fetch failed'))).toBe('fetch failed'); + }); + + it.each([ + ['a string', 'boom', 'boom'], + ['an object', { code: 'x' }, '[object Object]'], + ])('should convert %s to a string', (_label, value, expected) => { + expect(errorMessage(value)).toBe(expected); + }); +}); diff --git a/tests/unit/services/keyvault-checker.test.ts b/tests/unit/services/keyvault-checker.test.ts index 4d64f660..d9a4532f 100644 --- a/tests/unit/services/keyvault-checker.test.ts +++ b/tests/unit/services/keyvault-checker.test.ts @@ -4,18 +4,36 @@ * Unit tests for keyvault-checker service (ARM-based approach) */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; import { checkKeyVaultSecretAccess, - type TokenProviderFactory, + type KeyVaultArmClientFactory, type KeyVaultCheckContext, } from '../../../src/services/keyvault-checker.js'; -import { USER_AGENT } from '../../../src/lib/user-agent.js'; +import { ArmHttpClient, type ArmTokenCredential } from '../../../src/clients/arm-http-client.js'; +import { logger } from '../../../src/lib/logger.js'; + +/** Stands in for DefaultAzureCredential, which only the default client factory creates. */ +const identityMock = vi.hoisted(() => ({ + constructed: 0, + getToken: vi.fn(), +})); + +vi.mock('@azure/identity', async (importOriginal) => ({ + // Keep the real getBearerTokenProvider (the SDK token cycler) around the mocked credential. + ...(await importOriginal()), + DefaultAzureCredential: class { + constructor() { + identityMock.constructed++; + } + getToken = identityMock.getToken; + }, +})); describe('checkKeyVaultSecretAccess', () => { let mockGetToken: ReturnType; - let mockTokenProviderFactory: TokenProviderFactory; let mockArmRequest: ReturnType; + let mockArmClientFactory: KeyVaultArmClientFactory & ReturnType; const validSecretIdentifier = 'https://myvault.vault.azure.net/secrets/my-secret'; @@ -85,9 +103,16 @@ describe('checkKeyVaultSecretAccess', () => { '/subscriptions/sub-1/resourceGroups/vault-rg/providers/Microsoft.KeyVault/vaults/myvault'; beforeEach(() => { - mockGetToken = vi.fn().mockResolvedValue({ token: 'fake-arm-token' }); - mockTokenProviderFactory = vi.fn().mockReturnValue({ getToken: mockGetToken }); + mockGetToken = vi.fn().mockResolvedValue('fake-arm-token'); mockArmRequest = vi.fn(); + mockArmClientFactory = vi.fn().mockReturnValue({ + getToken: mockGetToken, + request: mockArmRequest, + }) as KeyVaultArmClientFactory & ReturnType; + }); + + afterEach(() => { + vi.restoreAllMocks(); }); /* ------------------------------------------------------------------ */ @@ -98,7 +123,7 @@ describe('checkKeyVaultSecretAccess', () => { await expect( checkKeyVaultSecretAccess( 'not-a-url', undefined, apimContext, - mockTokenProviderFactory, mockArmRequest, + mockArmClientFactory, ), ).rejects.toMatchObject({ name: 'KeyVaultAccessError', @@ -110,7 +135,7 @@ describe('checkKeyVaultSecretAccess', () => { await expect( checkKeyVaultSecretAccess( 'https://myvault.vault.azure.net/', undefined, apimContext, - mockTokenProviderFactory, mockArmRequest, + mockArmClientFactory, ), ).rejects.toMatchObject({ name: 'KeyVaultAccessError', @@ -128,13 +153,29 @@ describe('checkKeyVaultSecretAccess', () => { await expect( checkKeyVaultSecretAccess( validSecretIdentifier, undefined, apimContext, - mockTokenProviderFactory, mockArmRequest, + mockArmClientFactory, ), ).resolves.toBeUndefined(); expect(mockArmRequest).not.toHaveBeenCalled(); }); + it('should warn and skip when the ARM client cannot be created', async () => { + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => undefined); + const factory = vi.fn(() => { + throw new Error('Invalid AZURE_CLIENT_ID'); + }); + + await expect( + checkKeyVaultSecretAccess(validSecretIdentifier, undefined, apimContext, factory), + ).resolves.toBeUndefined(); + + expect(warnSpy).toHaveBeenCalledWith( + 'Unable to acquire ARM token — skipping Key Vault pre-flight check. (Error: Invalid AZURE_CLIENT_ID)', + ); + warnSpy.mockRestore(); + }); + /* ------------------------------------------------------------------ */ /* APIM identity resolution */ /* ------------------------------------------------------------------ */ @@ -145,7 +186,7 @@ describe('checkKeyVaultSecretAccess', () => { await expect( checkKeyVaultSecretAccess( validSecretIdentifier, undefined, apimContext, - mockTokenProviderFactory, mockArmRequest, + mockArmClientFactory, ), ).rejects.toMatchObject({ name: 'KeyVaultAccessError', @@ -161,7 +202,7 @@ describe('checkKeyVaultSecretAccess', () => { await expect( checkKeyVaultSecretAccess( validSecretIdentifier, undefined, apimContext, - mockTokenProviderFactory, mockArmRequest, + mockArmClientFactory, ), ).rejects.toMatchObject({ name: 'KeyVaultAccessError', @@ -177,7 +218,7 @@ describe('checkKeyVaultSecretAccess', () => { await expect( checkKeyVaultSecretAccess( validSecretIdentifier, 'missing-client-id', apimContext, - mockTokenProviderFactory, mockArmRequest, + mockArmClientFactory, ), ).rejects.toMatchObject({ name: 'KeyVaultAccessError', @@ -191,7 +232,7 @@ describe('checkKeyVaultSecretAccess', () => { await expect( checkKeyVaultSecretAccess( validSecretIdentifier, undefined, apimContext, - mockTokenProviderFactory, mockArmRequest, + mockArmClientFactory, ), ).resolves.toBeUndefined(); }); @@ -202,7 +243,7 @@ describe('checkKeyVaultSecretAccess', () => { await expect( checkKeyVaultSecretAccess( validSecretIdentifier, undefined, apimContext, - mockTokenProviderFactory, mockArmRequest, + mockArmClientFactory, ), ).resolves.toBeUndefined(); }); @@ -219,7 +260,7 @@ describe('checkKeyVaultSecretAccess', () => { await expect( checkKeyVaultSecretAccess( validSecretIdentifier, undefined, apimContext, - mockTokenProviderFactory, mockArmRequest, + mockArmClientFactory, ), ).resolves.toBeUndefined(); }); @@ -232,7 +273,7 @@ describe('checkKeyVaultSecretAccess', () => { await expect( checkKeyVaultSecretAccess( validSecretIdentifier, undefined, apimContext, - mockTokenProviderFactory, mockArmRequest, + mockArmClientFactory, ), ).resolves.toBeUndefined(); }); @@ -246,7 +287,7 @@ describe('checkKeyVaultSecretAccess', () => { await expect( checkKeyVaultSecretAccess( validSecretIdentifier, undefined, apimContext, - mockTokenProviderFactory, mockArmRequest, + mockArmClientFactory, ), ).resolves.toBeUndefined(); }); @@ -268,7 +309,7 @@ describe('checkKeyVaultSecretAccess', () => { await expect( checkKeyVaultSecretAccess( validSecretIdentifier, undefined, apimContext, - mockTokenProviderFactory, mockArmRequest, + mockArmClientFactory, ), ).resolves.toBeUndefined(); }); @@ -285,7 +326,7 @@ describe('checkKeyVaultSecretAccess', () => { await expect( checkKeyVaultSecretAccess( validSecretIdentifier, undefined, apimContext, - mockTokenProviderFactory, mockArmRequest, + mockArmClientFactory, ), ).resolves.toBeUndefined(); }); @@ -300,7 +341,7 @@ describe('checkKeyVaultSecretAccess', () => { await expect( checkKeyVaultSecretAccess( validSecretIdentifier, undefined, apimContext, - mockTokenProviderFactory, mockArmRequest, + mockArmClientFactory, ), ).rejects.toMatchObject({ name: 'KeyVaultAccessError', @@ -308,6 +349,44 @@ describe('checkKeyVaultSecretAccess', () => { }); }); + it('should log access as confirmed only when an RBAC role grants it', async () => { + const debugSpy = vi.spyOn(logger, 'debug').mockImplementation(() => undefined); + mockArmRequest + .mockResolvedValueOnce(armResponse(200, apimWithSystemIdentity('principal-1'))) + .mockResolvedValueOnce(armResponse(200, vaultListResponse(VAULT_RESOURCE_ID))) + .mockResolvedValueOnce(armResponse(200, vaultWithRbac())) + .mockResolvedValueOnce( + armResponse(200, { value: [roleAssignment('4633458b-17de-408a-b874-0445c86b69e6')] }), + ); + + await checkKeyVaultSecretAccess( + validSecretIdentifier, undefined, apimContext, + mockArmClientFactory, + ); + + expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining('Key Vault access confirmed')); + }); + + it('should not log access as confirmed when the RBAC check is skipped', async () => { + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => undefined); + const debugSpy = vi.spyOn(logger, 'debug').mockImplementation(() => undefined); + mockArmRequest + .mockResolvedValueOnce(armResponse(200, apimWithSystemIdentity('principal-1'))) + .mockResolvedValueOnce(armResponse(200, vaultListResponse(VAULT_RESOURCE_ID))) + .mockResolvedValueOnce(armResponse(200, vaultWithRbac())) + .mockResolvedValueOnce(armResponse(403, { error: { code: 'AuthorizationFailed' } })); + + await checkKeyVaultSecretAccess( + validSecretIdentifier, undefined, apimContext, + mockArmClientFactory, + ); + + expect(warnSpy).toHaveBeenCalledWith( + "Failed to list role assignments (HTTP 403) — skipping RBAC pre-flight check for 'myvault'.", + ); + expect(debugSpy).not.toHaveBeenCalledWith(expect.stringContaining('Key Vault access confirmed')); + }); + it('should succeed when custom role has getSecret dataAction (slow path)', async () => { const customRoleGuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; @@ -333,7 +412,7 @@ describe('checkKeyVaultSecretAccess', () => { await expect( checkKeyVaultSecretAccess( validSecretIdentifier, undefined, apimContext, - mockTokenProviderFactory, mockArmRequest, + mockArmClientFactory, ), ).resolves.toBeUndefined(); }); @@ -361,7 +440,7 @@ describe('checkKeyVaultSecretAccess', () => { await expect( checkKeyVaultSecretAccess( validSecretIdentifier, undefined, apimContext, - mockTokenProviderFactory, mockArmRequest, + mockArmClientFactory, ), ).resolves.toBeUndefined(); }); @@ -389,7 +468,7 @@ describe('checkKeyVaultSecretAccess', () => { await expect( checkKeyVaultSecretAccess( validSecretIdentifier, undefined, apimContext, - mockTokenProviderFactory, mockArmRequest, + mockArmClientFactory, ), ).rejects.toMatchObject({ name: 'KeyVaultAccessError', @@ -421,13 +500,71 @@ describe('checkKeyVaultSecretAccess', () => { await expect( checkKeyVaultSecretAccess( validSecretIdentifier, undefined, apimContext, - mockTokenProviderFactory, mockArmRequest, + mockArmClientFactory, + ), + ).rejects.toMatchObject({ + name: 'KeyVaultAccessError', + }); + }); + + it('should throw when the only custom role definition cannot be read', async () => { + const customRoleGuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; + + mockArmRequest + .mockResolvedValueOnce(armResponse(200, apimWithSystemIdentity('principal-1'))) + .mockResolvedValueOnce(armResponse(200, vaultListResponse(VAULT_RESOURCE_ID))) + .mockResolvedValueOnce(armResponse(200, vaultWithRbac())) + .mockResolvedValueOnce( + armResponse(200, { value: [roleAssignment(customRoleGuid)] }), + ) + .mockResolvedValueOnce(armResponse(403, { error: { code: 'AuthorizationFailed' } })); + + // An unreadable role definition is skipped, so no role is left to grant access. + await expect( + checkKeyVaultSecretAccess( + validSecretIdentifier, undefined, apimContext, + mockArmClientFactory, ), ).rejects.toMatchObject({ name: 'KeyVaultAccessError', + message: expect.stringContaining('data-action'), }); }); + it('should skip an unreadable role definition and confirm access from the next one', async () => { + const unreadableRoleGuid = 'ffffffff-bbbb-cccc-dddd-eeeeeeeeeeee'; + const grantingRoleGuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; + const debugSpy = vi.spyOn(logger, 'debug').mockImplementation(() => undefined); + + mockArmRequest + .mockResolvedValueOnce(armResponse(200, apimWithSystemIdentity('principal-1'))) + .mockResolvedValueOnce(armResponse(200, vaultListResponse(VAULT_RESOURCE_ID))) + .mockResolvedValueOnce(armResponse(200, vaultWithRbac())) + .mockResolvedValueOnce( + armResponse(200, { + value: [roleAssignment(unreadableRoleGuid), roleAssignment(grantingRoleGuid)], + }), + ) + .mockResolvedValueOnce(armResponse(403, { error: { code: 'AuthorizationFailed' } })) + .mockResolvedValueOnce( + armResponse(200, { + properties: { + permissions: [{ + dataActions: ['Microsoft.KeyVault/vaults/secrets/getSecret/action'], + }], + }, + }), + ); + + await expect( + checkKeyVaultSecretAccess( + validSecretIdentifier, undefined, apimContext, + mockArmClientFactory, + ), + ).resolves.toBeUndefined(); + expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining('Key Vault access confirmed')); + }); + it('should skip RBAC check when role assignments fetch fails', async () => { mockArmRequest .mockResolvedValueOnce(armResponse(200, apimWithSystemIdentity('principal-1'))) @@ -438,7 +575,7 @@ describe('checkKeyVaultSecretAccess', () => { await expect( checkKeyVaultSecretAccess( validSecretIdentifier, undefined, apimContext, - mockTokenProviderFactory, mockArmRequest, + mockArmClientFactory, ), ).resolves.toBeUndefined(); }); @@ -461,7 +598,7 @@ describe('checkKeyVaultSecretAccess', () => { await expect( checkKeyVaultSecretAccess( validSecretIdentifier, undefined, apimContext, - mockTokenProviderFactory, mockArmRequest, + mockArmClientFactory, ), ).resolves.toBeUndefined(); }); @@ -480,7 +617,7 @@ describe('checkKeyVaultSecretAccess', () => { await expect( checkKeyVaultSecretAccess( validSecretIdentifier, undefined, apimContext, - mockTokenProviderFactory, mockArmRequest, + mockArmClientFactory, ), ).resolves.toBeUndefined(); }); @@ -499,7 +636,24 @@ describe('checkKeyVaultSecretAccess', () => { await expect( checkKeyVaultSecretAccess( validSecretIdentifier, undefined, apimContext, - mockTokenProviderFactory, mockArmRequest, + mockArmClientFactory, + ), + ).rejects.toMatchObject({ + name: 'KeyVaultAccessError', + message: expect.stringContaining("does not have an access policy"), + }); + }); + + it('should throw when the vault has no access policies', async () => { + mockArmRequest + .mockResolvedValueOnce(armResponse(200, apimWithSystemIdentity('principal-1'))) + .mockResolvedValueOnce(armResponse(200, vaultListResponse(VAULT_RESOURCE_ID))) + .mockResolvedValueOnce(armResponse(200, { properties: { enableRbacAuthorization: false } })); + + await expect( + checkKeyVaultSecretAccess( + validSecretIdentifier, undefined, apimContext, + mockArmClientFactory, ), ).rejects.toMatchObject({ name: 'KeyVaultAccessError', @@ -521,7 +675,7 @@ describe('checkKeyVaultSecretAccess', () => { await expect( checkKeyVaultSecretAccess( validSecretIdentifier, undefined, apimContext, - mockTokenProviderFactory, mockArmRequest, + mockArmClientFactory, ), ).rejects.toMatchObject({ name: 'KeyVaultAccessError', @@ -529,6 +683,28 @@ describe('checkKeyVaultSecretAccess', () => { }); }); + it('should log access as confirmed when the principal\'s own access policy grants secret GET', async () => { + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => undefined); + const debugSpy = vi.spyOn(logger, 'debug').mockImplementation(() => undefined); + const policies = [ + { objectId: 'other-principal', permissions: { secrets: ['list'] } }, + { objectId: 'PRINCIPAL-1', permissions: { secrets: ['List', 'Get'] } }, + ]; + + mockArmRequest + .mockResolvedValueOnce(armResponse(200, apimWithSystemIdentity('principal-1'))) + .mockResolvedValueOnce(armResponse(200, vaultListResponse(VAULT_RESOURCE_ID))) + .mockResolvedValueOnce(armResponse(200, vaultWithAccessPolicies(policies))); + + await checkKeyVaultSecretAccess( + validSecretIdentifier, undefined, apimContext, + mockArmClientFactory, + ); + + expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining('Key Vault access confirmed')); + expect(warnSpy).not.toHaveBeenCalled(); + }); + /* ------------------------------------------------------------------ */ /* User-assigned identity */ /* ------------------------------------------------------------------ */ @@ -545,7 +721,7 @@ describe('checkKeyVaultSecretAccess', () => { await expect( checkKeyVaultSecretAccess( validSecretIdentifier, 'ua-client', apimContext, - mockTokenProviderFactory, mockArmRequest, + mockArmClientFactory, ), ).resolves.toBeUndefined(); @@ -572,8 +748,7 @@ describe('checkKeyVaultSecretAccess', () => { 'https://myvault.vault.azure.net/secrets/my-secret/abc123version', undefined, apimContext, - mockTokenProviderFactory, - mockArmRequest, + mockArmClientFactory, ), ).resolves.toBeUndefined(); }); @@ -607,7 +782,7 @@ describe('checkKeyVaultSecretAccess in sovereign clouds', () => { it.each(clouds)( 'should use the $name ARM scope and endpoint for every request', async ({ armBaseUrl, authScope, secretIdentifier }) => { - const getToken = vi.fn().mockResolvedValue({ token: 'fake-arm-token' }); + const getToken = vi.fn().mockResolvedValue('fake-arm-token'); const armRequest = vi.fn() .mockResolvedValueOnce(armResponse(200, { identity: { type: 'SystemAssigned', principalId: 'principal-1' }, @@ -623,6 +798,7 @@ describe('checkKeyVaultSecretAccess in sovereign clouds', () => { permissions: [{ dataActions: ['Microsoft.KeyVault/vaults/secrets/getSecret/action'] }], }, })); + const armClientFactory = vi.fn(() => ({ getToken, request: armRequest })); await expect( checkKeyVaultSecretAccess( @@ -635,12 +811,12 @@ describe('checkKeyVaultSecretAccess in sovereign clouds', () => { armBaseUrl, authScope, }, - () => ({ getToken }), - armRequest, + armClientFactory, ), ).resolves.toBeUndefined(); - expect(getToken).toHaveBeenCalledWith(authScope); + expect(armClientFactory).toHaveBeenCalledWith(authScope); + expect(getToken).toHaveBeenCalled(); const urls = armRequest.mock.calls.map((call) => call[0] as string); expect(urls).toHaveLength(5); @@ -682,8 +858,7 @@ describe('checkKeyVaultSecretAccess in sovereign clouds', () => { armBaseUrl: 'https://management.usgovcloudapi.net', authScope: 'https://management.usgovcloudapi.net/.default', }, - () => ({ getToken: vi.fn().mockResolvedValue({ token: 'fake-arm-token' }) }), - armRequest, + () => ({ getToken: vi.fn().mockResolvedValue('fake-arm-token'), request: armRequest }), ), ).resolves.toBeUndefined(); @@ -692,7 +867,7 @@ describe('checkKeyVaultSecretAccess in sovereign clouds', () => { }); }); -describe('defaultArmRequest User-Agent header', () => { +describe('checkKeyVaultSecretAccess through ArmHttpClient', () => { const apimContextForUserAgentTest: KeyVaultCheckContext = { subscriptionId: 'sub-1', resourceGroup: 'rg-1', @@ -701,10 +876,35 @@ describe('defaultArmRequest User-Agent header', () => { authScope: 'https://management.azure.com/.default', }; let fetchSpy: ReturnType; + let getToken: Mock; + let delays: number[]; + + /** Real ArmHttpClient with a stub credential and instant retry delays. */ + function armHttpClientFactory(authScope: string): ArmHttpClient { + return new ArmHttpClient({ + authScope, + credential: { getToken }, + delay: async (ms) => { + delays.push(ms); + }, + }); + } + + function jsonResponse(status: number, body: unknown, headers: Record = {}): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json', ...headers }, + }); + } beforeEach(() => { fetchSpy = vi.fn(); vi.stubGlobal('fetch', fetchSpy); + getToken = vi.fn().mockResolvedValue({ + token: 'fake-arm-token', + expiresOnTimestamp: Date.now() + 3600_000, + }); + delays = []; }); afterEach(() => { @@ -712,47 +912,135 @@ describe('defaultArmRequest User-Agent header', () => { vi.unstubAllGlobals(); }); - it('should include User-Agent header when defaultArmRequest is used', async () => { - // Arrange: mock all ARM responses so checkKeyVaultSecretAccess runs to completion - const apimIdentity = { - identity: { type: 'SystemAssigned', principalId: 'pid-1' }, - }; - const vaultList = { value: [{ id: '/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.KeyVault/vaults/myvault' }] }; - const vaultDetail = { - properties: { - enableRbacAuthorization: false, - accessPolicies: [ - { - objectId: 'pid-1', - permissions: { secrets: ['get'] }, - }, - ], - }, - }; + const VAULT_ID = '/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.KeyVault/vaults/myvault'; + it('should request the token for the cloud scope and send it as a bearer token', async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse(200, {})); + + await expect( + checkKeyVaultSecretAccess( + 'https://myvault.vault.usgovcloudapi.net/secrets/my-secret', + undefined, + { + ...apimContextForUserAgentTest, + armBaseUrl: 'https://management.usgovcloudapi.net', + authScope: 'https://management.usgovcloudapi.net/.default', + }, + armHttpClientFactory, + ), + ).rejects.toMatchObject({ name: 'KeyVaultAccessError' }); + + expect(getToken).toHaveBeenCalledTimes(1); + expect(getToken).toHaveBeenCalledWith('https://management.usgovcloudapi.net/.default'); + const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; + expect(new URL(url).origin).toBe('https://management.usgovcloudapi.net'); + const headers = new Headers(init.headers); + expect(headers.get('Authorization')).toBe('Bearer fake-arm-token'); + expect(headers.get('Accept')).toBe('application/json'); + }); + + it('should warn and skip when an ARM call fails with a client error, without retrying', async () => { + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => undefined); + fetchSpy.mockResolvedValueOnce(jsonResponse(403, { error: { code: 'AuthorizationFailed' } })); + await expect( + checkKeyVaultSecretAccess( + 'https://myvault.vault.azure.net/secrets/my-secret', + undefined, + apimContextForUserAgentTest, + armHttpClientFactory, + ), + ).resolves.toBeUndefined(); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith( + 'Failed to fetch APIM service (HTTP 403) — skipping Key Vault pre-flight check.', + ); + }); + + it('should warn and skip when an ARM call keeps failing with a server error', async () => { + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => undefined); fetchSpy - .mockResolvedValueOnce(new Response(JSON.stringify(apimIdentity), { status: 200, headers: { 'Content-Type': 'application/json' } })) - .mockResolvedValueOnce(new Response(JSON.stringify(vaultList), { status: 200, headers: { 'Content-Type': 'application/json' } })) - .mockResolvedValueOnce(new Response(JSON.stringify(vaultDetail), { status: 200, headers: { 'Content-Type': 'application/json' } })); + .mockResolvedValueOnce(jsonResponse(200, { identity: { type: 'SystemAssigned', principalId: 'pid-1' } })) + .mockImplementation(async () => new Response('down', { status: 503 })); - const mockTokenProviderFactory: TokenProviderFactory = () => ({ - getToken: vi.fn().mockResolvedValue({ token: 'fake-arm-token' }), - }); + await expect( + checkKeyVaultSecretAccess( + 'https://myvault.vault.azure.net/secrets/my-secret', + undefined, + apimContextForUserAgentTest, + armHttpClientFactory, + ), + ).resolves.toBeUndefined(); - await checkKeyVaultSecretAccess( - 'https://myvault.vault.azure.net/secrets/my-secret', - undefined, - apimContextForUserAgentTest, - mockTokenProviderFactory, - // No armRequest override — uses defaultArmRequest which must include User-Agent + // 1 APIM GET + 4 attempts at the vault search + expect(fetchSpy).toHaveBeenCalledTimes(5); + expect(warnSpy).toHaveBeenCalledWith( + "Failed to search for Key Vault 'myvault' (HTTP 503) — skipping pre-flight check.", ); + }); - // All three fetch calls should include the User-Agent header - expect(fetchSpy.mock.calls.length).toBeGreaterThan(0); - for (const call of fetchSpy.mock.calls) { - const [_url, init] = call as [string, RequestInit]; - const headers = new Headers(init?.headers); - expect(headers.get('User-Agent')).toBe(USER_AGENT); - } + it('should cancel the unread body of a 404 it skips the check for', async () => { + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => undefined); + const cancel = vi.fn(); + fetchSpy + .mockResolvedValueOnce(jsonResponse(200, { identity: { type: 'SystemAssigned', principalId: 'pid-1' } })) + .mockResolvedValueOnce(jsonResponse(200, { value: [{ id: VAULT_ID }] })) + .mockResolvedValueOnce(new Response(new ReadableStream({ cancel }), { status: 404 })); + + await expect( + checkKeyVaultSecretAccess( + 'https://myvault.vault.azure.net/secrets/my-secret', + undefined, + apimContextForUserAgentTest, + armHttpClientFactory, + ), + ).resolves.toBeUndefined(); + + expect(warnSpy).toHaveBeenCalledWith( + "Failed to read Key Vault 'myvault' (HTTP 404) — skipping pre-flight check.", + ); + expect(cancel).toHaveBeenCalledTimes(1); + }); +}); + +describe('checkKeyVaultSecretAccess with the default ARM client', () => { + // The default factory keeps its clients for the whole run (module), so each + // test uses auth scopes of its own. + function context(authScope: string): KeyVaultCheckContext { + return { + subscriptionId: 'sub-1', + resourceGroup: 'rg-1', + serviceName: 'apim-1', + armBaseUrl: 'https://management.azure.com', + authScope, + }; + } + + beforeEach(() => { + identityMock.constructed = 0; + identityMock.getToken.mockReset(); + identityMock.getToken.mockImplementation(async () => ({ + token: 'fake-arm-token', + expiresOnTimestamp: Date.now() + 60 * 60 * 1000, + })); + // Every check stops at the APIM service GET (404) and skips. + vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 404 }))); + vi.spyOn(logger, 'warn').mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('should reuse one client and its token for checks with the same auth scope', async () => { + const ctx = context('https://reuse.example/.default'); + + await checkKeyVaultSecretAccess('https://v1.vault.azure.net/secrets/s1', undefined, ctx); + await checkKeyVaultSecretAccess('https://v2.vault.azure.net/secrets/s2', undefined, ctx); + + expect(identityMock.constructed).toBe(1); + expect(identityMock.getToken).toHaveBeenCalledTimes(1); + expect(identityMock.getToken).toHaveBeenCalledWith('https://reuse.example/.default'); }); }); diff --git a/tests/unit/services/publish-service.test.ts b/tests/unit/services/publish-service.test.ts index 9c0c2826..04d5caac 100644 --- a/tests/unit/services/publish-service.test.ts +++ b/tests/unit/services/publish-service.test.ts @@ -9,10 +9,20 @@ import { ResourceType } from '../../../src/models/resource-types.js'; import { ResourceDescriptor, ApimServiceContext } from '../../../src/models/types.js'; import { PublishConfig } from '../../../src/models/config.js'; import { LogLevel } from '../../../src/lib/logger.js'; +import { getCloudConfig } from '../../../src/lib/cloud-config.js'; import { recordRetry, withRetryResource } from '../../../src/lib/retry-tracker.js'; import { getResourceDescriptorKey } from '../../../src/lib/resource-path.js'; import { ApimClient } from '../../../src/clients/apim-client.js'; +// The Key Vault pre-flight check creates its own ARM client with DefaultAzureCredential. +vi.mock('@azure/identity', async (importOriginal) => ({ + // Keep the real getBearerTokenProvider (the SDK token cycler) around the mocked credential. + ...(await importOriginal()), + DefaultAzureCredential: class { + getToken = async () => ({ token: 'fake-token', expiresOnTimestamp: Date.now() + 60 * 60 * 1000 }); + }, +})); + // Mock service dependencies vi.mock('../../../src/services/git-diff-service.js'); vi.mock('../../../src/services/dry-run-reporter.js'); @@ -1624,9 +1634,9 @@ describe('publish-service', () => { * Resource writes are stubbed on the instance so only pre-flight goes through fetch. */ function createClientWithForbiddenResourceGroup(serviceStatus: number) { - const client = new ApimClient(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - vi.spyOn(client as any, 'getToken').mockResolvedValue('fake-token'); + const client = new ApimClient(undefined, { + credential: { getToken: async () => ({ token: 'fake-token', expiresOnTimestamp: Date.now() + 60 * 60 * 1000 }) }, + }); vi.spyOn(client, 'getResource').mockResolvedValue(undefined); vi.spyOn(client, 'putResource').mockResolvedValue(undefined); vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string) => { @@ -1662,17 +1672,6 @@ describe('publish-service', () => { expect.anything(), ); }); - - it('should fail pre-flight when the resource group read is forbidden and the APIM service is not found', async () => { - const client = createClientWithForbiddenResourceGroup(404); - const store = createMockStore([{ type: ResourceType.Tag, nameParts: ['tag1'] }]); - - const result = await runPublish(client, store, config); - - expect(result.exitCode).toBe(2); - expect(result.totalErrors).toBe(1); - expect(client.putResource).not.toHaveBeenCalled(); - }); }); }); @@ -2268,6 +2267,7 @@ describe('publish-service', () => { describe('publish output readability', () => { const baseConfig: PublishConfig = { service: testContext, + cloud: getCloudConfig('public'), sourceDir: '/source', dryRun: false, deleteUnmatched: false, @@ -2336,6 +2336,36 @@ describe('publish-service', () => { } }); + it('should count Key Vault pre-flight check retries toward the named value', async () => { + const resources = [{ type: ResourceType.NamedValue, nameParts: ['kv-nv'] }]; + const store = createMockStore(resources); + store.readResource.mockResolvedValue({ + name: 'kv-nv', + properties: { + displayName: 'kv-nv', + secret: true, + keyVault: { secretIdentifier: 'https://myvault.vault.azure.net/secrets/my-secret' }, + }, + }); + // The check's APIM service GET is rate limited once, then not found + // (the check warns and skips); the named value itself is published. + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce(new Response('', { status: 429, headers: { 'Retry-After': '0' } })) + .mockResolvedValueOnce(new Response('{}', { status: 404 }))); + const stdout = captureStdout(); + + try { + const result = await runPublish(createMockClient(), store, baseConfig); + + expect(stdout.lines()).toContain('PUT namedvalue/kv-nv (1 retry)\n'); + expect(result.totalRetries).toBe(1); + expect(result.actions.find((a) => a.descriptor.nameParts[0] === 'kv-nv')?.retries).toBe(1); + } finally { + stdout.restore(); + vi.unstubAllGlobals(); + } + }); + it('attributes retries to the related resource that incurred them', async () => { const apiDescriptor = { type: ResourceType.Api, nameParts: ['api1'] }; const operationDescriptor = { From c580a459554775a720ff64f423e2000d720495bd Mon Sep 17 00:00:00 2001 From: Chris Dayne <1642847+cdayne@users.noreply.github.com> Date: Thu, 5 Nov 2026 07:08:37 +1100 Subject: [PATCH 08/13] fix: fail at once when the proxy refuses the request with 407 With wrong credentials in HTTPS_PROXY, every ARM request was retried three times and then failed with just "fetch failed": fetch puts the reason (here the proxy's 407 Proxy Authentication Required) on the error's cause, which no message showed. A request the proxy refuses with 407, to a CONNECT tunnel or a forwarded request, now fails at once, saying the proxy refused it and to check the user name and password in the proxy URL. Other network errors are still retried; when they outlast the retries, the error's message names its cause ("fetch failed (getaddrinfo ENOTFOUND ...)"), and errorMessage adds the causes of any error it is given. Both follow an error's causes with one helper, errorCauses, which stops at a cause already seen so a cycle of causes ends. Only ARM requests are covered. A token request the proxy refuses fails in @azure/identity with just "network_error", with no cause or status to tell it from any other network failure. Found by the ARM client integration tests. --- src/clients/arm-http-client.ts | 13 +++++- src/lib/error-message.ts | 30 ++++++++++++- src/lib/proxy.ts | 25 +++++++++++ tests/unit/clients/arm-http-client.test.ts | 22 ++++++++++ tests/unit/lib/error-message.test.ts | 32 +++++++++++++- tests/unit/lib/proxy.test.ts | 51 +++++++++++++++++++++- 6 files changed, 168 insertions(+), 5 deletions(-) diff --git a/src/clients/arm-http-client.ts b/src/clients/arm-http-client.ts index 159db63c..aea24348 100644 --- a/src/clients/arm-http-client.ts +++ b/src/clients/arm-http-client.ts @@ -15,6 +15,7 @@ import { DefaultAzureCredential, getBearerTokenProvider } from '@azure/identity' import { getArmHostsForAuthScope, getCloudConfig } from '../lib/cloud-config.js'; import { errorMessage } from '../lib/error-message.js'; import { formatDuration } from '../lib/format-duration.js'; +import { isProxyAuthenticationError } from '../lib/proxy.js'; import { logger } from '../lib/logger.js'; import { recordRetry } from '../lib/retry-tracker.js'; import { USER_AGENT } from '../lib/user-agent.js'; @@ -585,8 +586,18 @@ export class ArmHttpClient implements IArmHttpClient { ) { throw error; } + // Retrying with the same proxy credentials gets the same answer. + if (isProxyAuthenticationError(error)) { + throw new Error( + `${target} failed: the proxy refused the request with 407 Proxy Authentication Required. ` + + 'Check the user name and password in the proxy URL (HTTPS_PROXY, ALL_PROXY or HTTP_PROXY).', + { cause: error }, + ); + } if (attempt >= maxRetries) { - throw error; + // fetch's "fetch failed" says nothing on its own; name the cause. + const message = errorMessage(error); + throw error instanceof Error && message !== error.message ? new Error(message, { cause: error }) : error; } const delayMs = ArmHttpClient.backoff(attempt); logRetry( diff --git a/src/lib/error-message.ts b/src/lib/error-message.ts index d93b1a41..fc8a844d 100644 --- a/src/lib/error-message.ts +++ b/src/lib/error-message.ts @@ -4,7 +4,33 @@ * Message text of a caught value for log and error output. */ -/** The `message` of an `Error`, or the value converted to a string otherwise. */ +/** + * The errors `error` was caused by, in order, following `cause` while it is an + * `Error`. Stops at an error already seen, so a cycle of causes ends. + */ +export function* errorCauses(error: Error): Generator { + const seen = new Set([error]); + for (let cause = error.cause; cause instanceof Error && !seen.has(cause); cause = cause.cause) { + seen.add(cause); + yield cause; + } +} + +/** + * The `message` of an `Error`, or the value converted to a string otherwise. + * The messages of the errors it was caused by follow in parentheses: `fetch` + * rejects with just "fetch failed" and gives the reason (a refused + * connection, the proxy's answer) as its `cause`. + */ export function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); + if (!(error instanceof Error)) { + return String(error); + } + const causes: string[] = []; + for (const cause of errorCauses(error)) { + if (cause.message && !error.message.includes(cause.message) && !causes.includes(cause.message)) { + causes.push(cause.message); + } + } + return causes.length > 0 ? `${error.message} (${causes.join(': ')})` : error.message; } diff --git a/src/lib/proxy.ts b/src/lib/proxy.ts index ce2a8df6..4efe0b13 100644 --- a/src/lib/proxy.ts +++ b/src/lib/proxy.ts @@ -22,6 +22,7 @@ */ import { Agent, getGlobalDispatcher, ProxyAgent, setGlobalDispatcher, type Dispatcher } from 'undici'; +import { errorCauses } from './error-message.js'; import { logger } from './logger.js'; /** Proxy variables in the order the Azure SDK's proxy policy reads them. */ @@ -98,6 +99,30 @@ function createProxyDispatcher(proxyUrl: string, noProxy: readonly string[]): { }; } +/** + * The messages undici fails a request with when the proxy answers 407 Proxy + * Authentication Required: to a CONNECT for an HTTPS request, or to a + * forwarded plain-HTTP request. + */ +const PROXY_AUTHENTICATION_FAILURES = [ + 'Proxy response (407) !== 200 when HTTP Tunneling', + 'Proxy Authentication Required (407)', +]; + +/** + * Whether a request failed because the proxy refused it with 407 Proxy + * Authentication Required: the proxy URL has no credentials, or wrong ones. + * `fetch` gives undici's error as the cause of its "fetch failed". + */ +export function isProxyAuthenticationError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + return [error, ...errorCauses(error)].some(({ message }) => + PROXY_AUTHENTICATION_FAILURES.some((failure) => message.includes(failure)) + ); +} + export type ProxySetupResult = /** No proxy variable is set. */ | { status: 'none' } diff --git a/tests/unit/clients/arm-http-client.test.ts b/tests/unit/clients/arm-http-client.test.ts index 10b32267..b705a1ae 100644 --- a/tests/unit/clients/arm-http-client.test.ts +++ b/tests/unit/clients/arm-http-client.test.ts @@ -614,6 +614,28 @@ describe('ArmHttpClient.request errors and retries', () => { expect(delays).toEqual([1000]); }); + it('should name the cause of a network error that outlasts the retries', async () => { + fetchSpy.mockRejectedValue(new TypeError('fetch failed', { cause: new Error('getaddrinfo ENOTFOUND example.test') })); + + const error = await client().request(URL_1).catch((e: unknown) => e); + + expect((error as Error).message).toBe('fetch failed (getaddrinfo ENOTFOUND example.test)'); + expect((error as Error).cause).toBeInstanceOf(TypeError); + expect(fetchSpy).toHaveBeenCalledTimes(4); + }); + + it('should not retry a request the proxy refuses with 407', async () => { + fetchSpy.mockRejectedValue( + new TypeError('fetch failed', { cause: new Error('Proxy response (407) !== 200 when HTTP Tunneling') }), + ); + + const error = await client().request(URL_1).catch((e: unknown) => e); + + expect((error as Error).message).toMatch(/failed: the proxy refused the request with 407 Proxy Authentication Required\. Check the user name and password/); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(delays).toEqual([]); + }); + it('should wait for numeric and HTTP-date Retry-After on 429', async () => { vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); diff --git a/tests/unit/lib/error-message.test.ts b/tests/unit/lib/error-message.test.ts index 9c2e7e0c..f3853234 100644 --- a/tests/unit/lib/error-message.test.ts +++ b/tests/unit/lib/error-message.test.ts @@ -5,13 +5,43 @@ */ import { describe, it, expect } from 'vitest'; -import { errorMessage } from '../../../src/lib/error-message.js'; +import { errorCauses, errorMessage } from '../../../src/lib/error-message.js'; describe('errorMessage', () => { it('should return the message of an Error', () => { expect(errorMessage(new TypeError('fetch failed'))).toBe('fetch failed'); }); + it('should add the messages of the errors it was caused by', () => { + const error = new TypeError('fetch failed', { + cause: new Error('connect ECONNREFUSED 127.0.0.1:8080', { cause: new Error('socket closed') }), + }); + + expect(errorMessage(error)).toBe('fetch failed (connect ECONNREFUSED 127.0.0.1:8080: socket closed)'); + }); + + it('should leave out a cause the message already says, and stop at a cycle', () => { + const error = new Error('HTTP 400: bad', { cause: new Error('bad') }); + const cyclic = new Error('outer'); + cyclic.cause = cyclic; + + expect(errorMessage(error)).toBe('HTTP 400: bad'); + expect(errorMessage(cyclic)).toBe('outer'); + }); + + it('should follow a long chain of causes and stop at a cycle further down', () => { + const deepest = new Error('level 7'); + let error = deepest; + for (let level = 6; level >= 1; level--) { + error = new Error(`level ${level}`, { cause: error }); + } + const top = new Error('top', { cause: error }); + deepest.cause = error; + + expect(errorMessage(top)).toBe('top (level 1: level 2: level 3: level 4: level 5: level 6: level 7)'); + expect([...errorCauses(top)].map((cause) => cause.message)).toHaveLength(7); + }); + it.each([ ['a string', 'boom', 'boom'], ['an object', { code: 'x' }, '[object Object]'], diff --git a/tests/unit/lib/proxy.test.ts b/tests/unit/lib/proxy.test.ts index 9ba59a42..e31b196e 100644 --- a/tests/unit/lib/proxy.test.ts +++ b/tests/unit/lib/proxy.test.ts @@ -11,7 +11,13 @@ import type { AddressInfo } from 'node:net'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getGlobalDispatcher } from 'undici'; import { logger } from '../../../src/lib/logger.js'; -import { applyProxyFromEnv, isProxyBypassed, parseNoProxy, type ProxySetupResult } from '../../../src/lib/proxy.js'; +import { + applyProxyFromEnv, + isProxyAuthenticationError, + isProxyBypassed, + parseNoProxy, + type ProxySetupResult, +} from '../../../src/lib/proxy.js'; describe('parseNoProxy', () => { it('should split on commas, trim entries and drop empty ones', () => { @@ -186,3 +192,46 @@ describe('applyProxyFromEnv', () => { expect(await fetchTarget()).toBe('direct'); }); }); + +describe('isProxyAuthenticationError', () => { + let server: http.Server; + let result: ProxySetupResult | undefined; + + beforeEach(async () => { + // A proxy that wants credentials it never accepts. + server = http.createServer((_request, response) => { + response.writeHead(407, { 'Proxy-Authenticate': 'Basic realm="proxy"' }).end(); + }); + server.on('connect', (_request, socket) => { + socket.end('HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm="proxy"\r\n\r\n'); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address() as AddressInfo; + result = applyProxyFromEnv({ HTTPS_PROXY: `http://user:wrong@127.0.0.1:${port}` }); + }); + + afterEach(async () => { + if (result?.status === 'applied') { + await result.restore(); + } + await close(server); + }); + + it.each([ + ['an HTTPS request the proxy refuses to tunnel', 'https://management.azure.com/'], + ['a plain-HTTP request the proxy refuses to forward', 'http://example.test/'], + ])('should recognise %s with 407', async (_label, url) => { + const error = await fetch(url).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(TypeError); + expect(isProxyAuthenticationError(error)).toBe(true); + }); + + it.each([ + ['another proxy answer', new TypeError('fetch failed', { cause: new Error('Proxy response (403) !== 200 when HTTP Tunneling') })], + ['a refused connection', new TypeError('fetch failed', { cause: new Error('connect ECONNREFUSED 127.0.0.1:1') })], + ['a value that is not an error', 'Proxy Authentication Required (407)'], + ])('should not match %s', (_label, error) => { + expect(isProxyAuthenticationError(error)).toBe(false); + }); +}); From 7c221be7edb970b5dac85db849a0c98b843ea454 Mon Sep 17 00:00:00 2001 From: Chris Dayne <1642847+cdayne@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:11:08 +1100 Subject: [PATCH 09/13] fix: mask a SAS signature written in any mix of escaped forms The request's own signature was masked only in a fixed list of whole forms (as sent, decoded, percent-encoded once or twice, and two JSON-escaped variants). A page that wrote its + characters as HTML character references (+, +), or escaped only some of them, left the signature readable in the error message and in error.body. The signature is now matched with each +, / and = in any of its forms (as is, percent-encoded once or twice, JSON-escaped or as an HTML character reference), in any mix. --- src/clients/arm-http-client.ts | 46 ++++++++++++++-------- tests/unit/clients/arm-http-client.test.ts | 2 + 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/src/clients/arm-http-client.ts b/src/clients/arm-http-client.ts index aea24348..890c643d 100644 --- a/src/clients/arm-http-client.ts +++ b/src/clients/arm-http-client.ts @@ -148,11 +148,37 @@ function escapeRegExp(text: string): string { return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } +/** + * The ways a signature's `+`, `/` and `=` can be written in text: as they + * are, percent-encoded once or twice, JSON-escaped, or as an HTML character + * reference. Matched ignoring case, as the escapes can be written either way. + */ +const SIGNATURE_CHARACTER_FORMS: Readonly> = { + '+': ['+', '%2B', '%252B', '\\u002B', '+', '+'], + '/': ['/', '%2F', '%252F', '\\/', '\\u002F', '/', '/'], + '=': ['=', '%3D', '%253D', '\\u003D', '=', '='], +}; + +/** + * A pattern matching `signature` with each `+`, `/` and `=` written in any of + * its forms, in any mix: a page can escape some characters and not others. + */ +function signaturePattern(signature: string): RegExp { + const source = [...signature] + .map((character) => { + const forms = SIGNATURE_CHARACTER_FORMS[character]; + return forms ? `(?:${forms.map(escapeRegExp).join('|')})` : escapeRegExp(character); + }) + .join(''); + return new RegExp(source, 'gi'); +} + /** * `text` with the SAS signature masked: the value of any `sig` parameter, * also in a percent-encoded URL, and the request's own signature wherever - * else it appears (as sent, decoded, encoded once or twice, or JSON-escaped - * with `\/` and `\u002B`), for example in a proxy's or firewall's page that + * else it appears, with its `+`, `/` and `=` as they are, percent-encoded once + * or twice, JSON-escaped or written as HTML character references, in any mix; + * for example in a proxy's or firewall's page that * quotes or links to the URL. Azure Storage's own authentication errors (for * example 'Signature did not match') don't quote it, but the body is text * this client doesn't control. The request's signature is masked on its own @@ -175,21 +201,7 @@ export function maskSasSignature(text: string, url: string): string { } } if (signature && /^[A-Za-z0-9+/]+={0,2}$/.test(signature) && Buffer.from(signature, 'base64').length === 32) { - const forms = new Set([ - signature, - encodeURIComponent(signature), - encodeURIComponent(encodeURIComponent(signature)), - raw ?? signature, - encodeURIComponent(raw ?? signature), - // JSON-escaped: '/' as \/ and '+' as \u002B ('gi' below also matches \u002b). - signature.replace(/\//g, '\\/'), - signature.replace(/\+/g, '\\u002B'), - signature.replace(/\//g, '\\/').replace(/\+/g, '\\u002B'), - ]); - // Longest first, so an encoded form is masked whole. - for (const form of [...forms].sort((x, y) => y.length - x.length)) { - masked = masked.replace(new RegExp(escapeRegExp(form), 'gi'), MASKED_SIGNATURE); - } + masked = masked.replace(signaturePattern(signature), MASKED_SIGNATURE); } return masked; } diff --git a/tests/unit/clients/arm-http-client.test.ts b/tests/unit/clients/arm-http-client.test.ts index b705a1ae..1e76b871 100644 --- a/tests/unit/clients/arm-http-client.test.ts +++ b/tests/unit/clients/arm-http-client.test.ts @@ -383,6 +383,8 @@ describe('maskSasSignature', () => { ['as sent', `x ${encoded} y`, 'x *** y'], ['as sent, with lower-case escapes', `x ${encoded.toLowerCase()} y`, 'x *** y'], ['decoded', `blocked (${signature})`, 'blocked (***)'], + ['with HTML character references', `

${signature.replace(/\+/g, '+').replace('=', '=')}

`, '

***

'], + ['with only some characters escaped', `{"sig":"${signature.replace('+', '\\u002B').replace('=', '%3D')}"}`, '{"sig":"***"}'], [ 'in a link to the percent-encoded URL', `href="https%3A%2F%2Fblob.example.net%2Fa%3Fsv%3D1%26sig%3D${encodeURIComponent(encoded)}%26se%3D2"`, From 7054166ed8c0b7e08fc2c250df06137513cc2f48 Mon Sep 17 00:00:00 2001 From: Chris Dayne <1642847+cdayne@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:11:56 +1100 Subject: [PATCH 10/13] test: give publish config fixtures the required cloud PublishConfig.cloud is required, so that the Key Vault pre-flight check always uses the cloud selected with --cloud, but the test fixtures that build a PublishConfig left it out. Vitest doesn't type-check, so the tests still ran; type-checking them reported 45 missing properties. Each fixture now sets cloud to the public cloud. --- tests/unit/services/api-publisher.test.ts | 2 + .../services/delete-unmatched-service.test.ts | 2 + .../delete-unmatched.env-mapping.test.ts | 2 + tests/unit/services/dry-run-reporter.test.ts | 2 + tests/unit/services/product-publisher.test.ts | 2 + .../publish-service.env-mapping.test.ts | 2 + tests/unit/services/publish-service.test.ts | 37 +++++++++++++++++++ .../resource-publisher.env-mapping.test.ts | 2 + .../services/secret-redaction-guard.test.ts | 2 + 9 files changed, 53 insertions(+) diff --git a/tests/unit/services/api-publisher.test.ts b/tests/unit/services/api-publisher.test.ts index 6e3592fa..a1f5877f 100644 --- a/tests/unit/services/api-publisher.test.ts +++ b/tests/unit/services/api-publisher.test.ts @@ -12,6 +12,7 @@ import { PublishConfig } from '../../../src/models/config.js'; import { LogLevel } from '../../../src/lib/logger.js'; import { applyOverrides } from '../../../src/services/override-merger.js'; import { DEFAULT_APPLIES_TO, EnvMapping } from '../../../src/services/env-mapper.js'; +import { getCloudConfig } from '../../../src/lib/cloud-config.js'; // Mock resource-publisher so we can verify call sequence const mockPublishResource = vi.fn(); @@ -81,6 +82,7 @@ const testContext: ApimServiceContext = { }; const testConfig: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, diff --git a/tests/unit/services/delete-unmatched-service.test.ts b/tests/unit/services/delete-unmatched-service.test.ts index 615b567e..12ec7342 100644 --- a/tests/unit/services/delete-unmatched-service.test.ts +++ b/tests/unit/services/delete-unmatched-service.test.ts @@ -14,6 +14,7 @@ import { ApimServiceContext, ResourceDescriptor } from '../../../src/models/type import { PublishConfig } from '../../../src/models/config.js'; import { LogLevel } from '../../../src/lib/logger.js'; import { buildEnvMapping } from '../../../src/services/env-mapper.js'; +import { getCloudConfig } from '../../../src/lib/cloud-config.js'; function createMockClient(apimResources: Map[]> = new Map()) { return { @@ -55,6 +56,7 @@ const testContext: ApimServiceContext = { }; const testConfig: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, diff --git a/tests/unit/services/delete-unmatched.env-mapping.test.ts b/tests/unit/services/delete-unmatched.env-mapping.test.ts index 9d114e6d..81c3a1bf 100644 --- a/tests/unit/services/delete-unmatched.env-mapping.test.ts +++ b/tests/unit/services/delete-unmatched.env-mapping.test.ts @@ -15,6 +15,7 @@ import type { ApimServiceContext, ResourceDescriptor } from '../../../src/models import type { PublishConfig } from '../../../src/models/config.js'; import type { EnvMapping } from '../../../src/services/env-mapper.js'; import { LogLevel } from '../../../src/lib/logger.js'; +import { getCloudConfig } from '../../../src/lib/cloud-config.js'; // ── Mock helpers ────────────────────────────────────────────────────────────── @@ -61,6 +62,7 @@ const testContext: ApimServiceContext = { /** Config with NO envMapping — exercises original code path. */ const baseConfig: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, diff --git a/tests/unit/services/dry-run-reporter.test.ts b/tests/unit/services/dry-run-reporter.test.ts index dc530e9e..5ad67a0e 100644 --- a/tests/unit/services/dry-run-reporter.test.ts +++ b/tests/unit/services/dry-run-reporter.test.ts @@ -11,6 +11,7 @@ import { ResourceType } from '../../../src/models/resource-types.js'; import { ApimServiceContext, ResourceDescriptor } from '../../../src/models/types.js'; import { PublishConfig } from '../../../src/models/config.js'; import { LogLevel, logger } from '../../../src/lib/logger.js'; +import { getCloudConfig } from '../../../src/lib/cloud-config.js'; type MockApimClient = IApimClient & { getResource: ReturnType; @@ -63,6 +64,7 @@ const testContext: ApimServiceContext = { }; const testConfig: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: true, diff --git a/tests/unit/services/product-publisher.test.ts b/tests/unit/services/product-publisher.test.ts index 27d1a833..d4fa9dc8 100644 --- a/tests/unit/services/product-publisher.test.ts +++ b/tests/unit/services/product-publisher.test.ts @@ -14,6 +14,7 @@ import { ApimServiceContext, ResourceDescriptor } from '../../../src/models/type import { PublishConfig } from '../../../src/models/config.js'; import { LogLevel } from '../../../src/lib/logger.js'; import { DEFAULT_APPLIES_TO } from '../../../src/services/env-mapper.js'; +import { getCloudConfig } from '../../../src/lib/cloud-config.js'; // Mock resource-publisher so product-publisher tests don't run full resource-publisher logic const mockPublishResource = vi.fn(); @@ -57,6 +58,7 @@ const testContext: ApimServiceContext = { }; const testConfig: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, diff --git a/tests/unit/services/publish-service.env-mapping.test.ts b/tests/unit/services/publish-service.env-mapping.test.ts index b81f4da9..c456eec8 100644 --- a/tests/unit/services/publish-service.env-mapping.test.ts +++ b/tests/unit/services/publish-service.env-mapping.test.ts @@ -11,6 +11,7 @@ import type { ResourceDescriptor } from '../../../src/models/types.js'; import type { OverrideConfig, PublishConfig } from '../../../src/models/config.js'; import { LogLevel, logger } from '../../../src/lib/logger.js'; import type { ApimServiceContext } from '../../../src/models/types.js'; +import { getCloudConfig } from '../../../src/lib/cloud-config.js'; // ─── Helpers ──────────────────────────────────────────────────────────────── @@ -25,6 +26,7 @@ const testContext: ApimServiceContext = { function makeConfig(overrides?: OverrideConfig): PublishConfig { return { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, diff --git a/tests/unit/services/publish-service.test.ts b/tests/unit/services/publish-service.test.ts index 04d5caac..8ba748fe 100644 --- a/tests/unit/services/publish-service.test.ts +++ b/tests/unit/services/publish-service.test.ts @@ -123,6 +123,7 @@ describe('publish-service', () => { const store = createMockStore(resources); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -171,6 +172,7 @@ describe('publish-service', () => { const store = createMockStore(resources); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -798,6 +800,7 @@ describe('publish-service', () => { const store = createMockStore(resources); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -821,6 +824,7 @@ describe('publish-service', () => { const store = createMockStore(resources); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -842,6 +846,7 @@ describe('publish-service', () => { const store = createMockStore(resources); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -882,6 +887,7 @@ describe('publish-service', () => { }); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -916,6 +922,7 @@ describe('publish-service', () => { }); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -956,6 +963,7 @@ describe('publish-service', () => { }); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -986,6 +994,7 @@ describe('publish-service', () => { }); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -1017,6 +1026,7 @@ describe('publish-service', () => { const store = createMockStore(resources); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: true, @@ -1079,6 +1089,7 @@ describe('publish-service', () => { ); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -1108,6 +1119,7 @@ describe('publish-service', () => { }); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: true, @@ -1136,6 +1148,7 @@ describe('publish-service', () => { }); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -1163,6 +1176,7 @@ describe('publish-service', () => { }); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -1293,6 +1307,7 @@ describe('publish-service', () => { }); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -1331,6 +1346,7 @@ describe('publish-service', () => { }); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: true, @@ -1364,6 +1380,7 @@ describe('publish-service', () => { ]); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -1388,6 +1405,7 @@ describe('publish-service', () => { const client = createMockClient(); const store = createMockStore([]); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -1422,6 +1440,7 @@ describe('publish-service', () => { ]); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -1456,6 +1475,7 @@ describe('publish-service', () => { const store = createMockStore(resources); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -1480,6 +1500,7 @@ describe('publish-service', () => { const store = createMockStore(resources); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -1521,6 +1542,7 @@ describe('publish-service', () => { ); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -1543,6 +1565,7 @@ describe('publish-service', () => { const store = createMockStore([{ type: ResourceType.Tag, nameParts: ['tag1'] }]); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -1563,6 +1586,7 @@ describe('publish-service', () => { const store = createMockStore([]); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -1584,6 +1608,7 @@ describe('publish-service', () => { const store = createMockStore([]); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -1605,6 +1630,7 @@ describe('publish-service', () => { const store = createMockStore([{ type: ResourceType.Tag, nameParts: ['tag1'] }]); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -1621,6 +1647,7 @@ describe('publish-service', () => { const rgUrl = 'https://management.azure.com/subscriptions/sub-1/resourceGroups/rg-1?api-version=2021-04-01'; const serviceUrl = `${testContext.baseUrl}?api-version=${testContext.apiVersion}`; const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -1699,6 +1726,7 @@ describe('publish-service', () => { ); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -1752,6 +1780,7 @@ describe('publish-service', () => { ); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -1814,6 +1843,7 @@ describe('publish-service', () => { ); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -1848,6 +1878,7 @@ describe('publish-service', () => { }); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -1887,6 +1918,7 @@ describe('publish-service', () => { ); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -1922,6 +1954,7 @@ describe('publish-service', () => { ); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -1946,6 +1979,7 @@ describe('publish-service', () => { const store = createMockStore(resources); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -2081,6 +2115,7 @@ describe('publish-service', () => { const store = createMockStore(resources); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -2121,6 +2156,7 @@ describe('publish-service', () => { ); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -2159,6 +2195,7 @@ describe('publish-service', () => { ); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, diff --git a/tests/unit/services/resource-publisher.env-mapping.test.ts b/tests/unit/services/resource-publisher.env-mapping.test.ts index 45eea59c..523096be 100644 --- a/tests/unit/services/resource-publisher.env-mapping.test.ts +++ b/tests/unit/services/resource-publisher.env-mapping.test.ts @@ -13,6 +13,7 @@ import type { PublishConfig, KnownArtifactSets } from '../../../src/models/confi import type { EnvMapping } from '../../../src/services/env-mapper.js'; import { DEFAULT_APPLIES_TO } from '../../../src/services/env-mapper.js'; import { LogLevel } from '../../../src/lib/logger.js'; +import { getCloudConfig } from '../../../src/lib/cloud-config.js'; // Mock keyvault-checker vi.mock('../../../src/services/keyvault-checker.js', () => ({ @@ -63,6 +64,7 @@ const testContext: ApimServiceContext = { }; const baseConfig: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, diff --git a/tests/unit/services/secret-redaction-guard.test.ts b/tests/unit/services/secret-redaction-guard.test.ts index 381bb9aa..db253a42 100644 --- a/tests/unit/services/secret-redaction-guard.test.ts +++ b/tests/unit/services/secret-redaction-guard.test.ts @@ -11,6 +11,7 @@ import { ApimServiceContext, ResourceDescriptor } from '../../../src/models/type import { PublishConfig } from '../../../src/models/config.js'; import { LogLevel } from '../../../src/lib/logger.js'; import { REDACTION_MARKER } from '../../../src/services/secret-redactor.js'; +import { getCloudConfig } from '../../../src/lib/cloud-config.js'; function createMockStore() { return { @@ -35,6 +36,7 @@ const testContext: ApimServiceContext = { }; const testConfig: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, From 46f1255f323253226de7732d2ff405444b6ffbfe Mon Sep 17 00:00:00 2001 From: Chris Dayne <1642847+cdayne@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:14:25 +1100 Subject: [PATCH 11/13] fix: get the bearer token before each retry The token was fetched once before the retry loop, so a long server-requested wait could leave the next attempt with an expired token. It is now asked for before every attempt; the provider returns its cached token until refresh is due. --- src/clients/arm-http-client.ts | 7 +++++-- tests/unit/clients/arm-http-client.test.ts | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/clients/arm-http-client.ts b/src/clients/arm-http-client.ts index 890c643d..7f437457 100644 --- a/src/clients/arm-http-client.ts +++ b/src/clients/arm-http-client.ts @@ -504,8 +504,6 @@ export class ArmHttpClient implements IArmHttpClient { const headers = new Headers(init.headers); if (!skipAuth) { - const token = await this.getToken(); - headers.set('Authorization', `Bearer ${token}`); headers.set('Content-Type', 'application/json'); } else { headers.delete('Authorization'); @@ -523,6 +521,11 @@ export class ArmHttpClient implements IArmHttpClient { const maxAttempts = maxRetries + 1; while (attempt <= maxRetries) { + if (!skipAuth) { + // Asked for on every attempt: a server-requested wait can outlast the + // token, and the provider returns its cached token until it is due. + headers.set('Authorization', `Bearer ${await this.getToken()}`); + } try { logger.debug(`HTTP ${method} ${logUrl}`); const response = await fetch(url, { ...init, headers }); diff --git a/tests/unit/clients/arm-http-client.test.ts b/tests/unit/clients/arm-http-client.test.ts index 1e76b871..bd686594 100644 --- a/tests/unit/clients/arm-http-client.test.ts +++ b/tests/unit/clients/arm-http-client.test.ts @@ -448,6 +448,21 @@ describe('maskSasSignature', () => { }); describe('ArmHttpClient.request errors and retries', () => { + it('should ask for the token again before each retry', async () => { + const http = client(); + vi.spyOn(http, 'getToken').mockResolvedValueOnce('t1').mockResolvedValueOnce('t2'); + const sent: (string | null)[] = []; + const responses = [empty(429, { 'Retry-After': '3600' }), json(200, {})]; + fetchSpy.mockImplementation(async (_url, init) => { + sent.push(new Headers(init?.headers).get('Authorization')); + return responses.shift()!; + }); + + await http.request(URL_1); + + expect(sent).toEqual(['Bearer t1', 'Bearer t2']); + }); + it('should mask the signature in a failed SAS download, naming the URL without its query', async () => { const signature = '++++QUJD++++QUJD++++QUJD++++QUJD++++QUJD++8='; // A proxy's block page that quotes the request URL, signature and all. From 0ab4f42546a000875c2d2fd09994585da2acca36 Mon Sep 17 00:00:00 2001 From: Chris Dayne <1642847+cdayne@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:31:44 +1100 Subject: [PATCH 12/13] fix: mask the request's own SAS signature before sig= parameters The sig= parameter rule stops at an HTML character reference, so for a link written as ?sig=A+... it masked only the first character. The full-signature pattern then no longer matched, and the rest of the signature stayed in the error message and HttpError.body. The request's own signature is now masked first. --- src/clients/arm-http-client.ts | 7 +++++-- tests/unit/clients/arm-http-client.test.ts | 8 ++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/clients/arm-http-client.ts b/src/clients/arm-http-client.ts index 7f437457..61fa4b9c 100644 --- a/src/clients/arm-http-client.ts +++ b/src/clients/arm-http-client.ts @@ -188,7 +188,7 @@ function signaturePattern(signature: string): RegExp { * the download failed. */ export function maskSasSignature(text: string, url: string): string { - let masked = text.replace(SIG_PARAMETER, `$1$2${MASKED_SIGNATURE}`); + let masked = text; // Read the value as sent and decode only its %XX escapes: URLSearchParams // would turn an unencoded '+' into a space. const raw = /[?&]sig=([^&#]*)/i.exec(url)?.[1]; @@ -200,10 +200,13 @@ export function maskSasSignature(text: string, url: string): string { // A malformed escape: compare the value as sent. } } + // The request's own signature goes first: the parameter rule stops at an + // escape it doesn't know (an HTML character reference), and masking only + // the start of the signature would leave the rest for this to miss. if (signature && /^[A-Za-z0-9+/]+={0,2}$/.test(signature) && Buffer.from(signature, 'base64').length === 32) { masked = masked.replace(signaturePattern(signature), MASKED_SIGNATURE); } - return masked; + return masked.replace(SIG_PARAMETER, `$1$2${MASKED_SIGNATURE}`); } /** diff --git a/tests/unit/clients/arm-http-client.test.ts b/tests/unit/clients/arm-http-client.test.ts index bd686594..ec47d415 100644 --- a/tests/unit/clients/arm-http-client.test.ts +++ b/tests/unit/clients/arm-http-client.test.ts @@ -420,6 +420,14 @@ describe('maskSasSignature', () => { expect(maskSasSignature(text, url)).toBe(expected); }); + it('should mask a signature with HTML character references in a sig= parameter', () => { + const startsWithLetter = 'QUJD++++QUJD++++QUJD++++QUJD++++QUJD++++++8='; + const sasUrl = `https://blob.example.net/a?sv=1&sig=${encodeURIComponent(startsWithLetter)}&se=2`; + const text = `href="/a?sv=1&sig=${startsWithLetter.replace(/\+/g, '+')}&se=2"`; + + expect(maskSasSignature(text, sasUrl)).toBe('href="/a?sv=1&sig=***&se=2"'); + }); + it("should mask a signature sent with an unencoded '+'", () => { const unencoded = `https://blob.example.net/a?sv=1&sig=${signature}&se=2`; From 0eb9e12cd1c6376eca75fc8bc41a14e73e53c5e1 Mon Sep 17 00:00:00 2001 From: Chris Dayne <1642847+cdayne@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:43:28 +1100 Subject: [PATCH 13/13] fix: apply each service's request options to every ARM request APIM's retry of a 409 PessimisticConcurrencyConflict was merged into each request by ApimClient, so the shared LRO poller, which calls the ARM client directly, lost it: a 409 while polling failed the PUT or DELETE at once, where main retried it. Each service now gives its ArmHttpClient its request options once, and the client applies them to every request, including pagination and polling; a request's own options still override them. --- src/clients/apic-client.ts | 35 +++++++------------ src/clients/apim-client.ts | 39 +++++++--------------- src/clients/arm-http-client.ts | 25 ++++++++------ tests/unit/clients/apim-client.test.ts | 19 +++++++++++ tests/unit/clients/arm-http-client.test.ts | 13 ++++---- 5 files changed, 65 insertions(+), 66 deletions(-) diff --git a/src/clients/apic-client.ts b/src/clients/apic-client.ts index 1abd220c..306dceef 100644 --- a/src/clients/apic-client.ts +++ b/src/clients/apic-client.ts @@ -24,7 +24,7 @@ import { ArmHttpClient, ArmHttpClientOptions, HttpError, - ArmRequestOptions, + ArmClientRequestOptions, discardBody, AsyncOperation, extractAsyncOperation, @@ -55,7 +55,7 @@ export class ApicClient implements IApicClient { /** Default interval between LRO polls when no Retry-After header is present. */ private static readonly ASYNC_POLL_INTERVAL_MS = 5000; /** API Center returns (rather than throws) a 404 for GET and DELETE. */ - private static readonly REQUEST_OPTIONS: ArmRequestOptions = { + private static readonly REQUEST_OPTIONS: ArmClientRequestOptions = { passthrough404Methods: ['GET', 'DELETE'], }; @@ -66,16 +66,7 @@ export class ApicClient implements IApicClient { * client. Default to `DefaultAzureCredential` and a `setTimeout` wait. */ constructor(authScope?: string, httpOptions: Omit = {}) { - this.http = new ArmHttpClient({ ...httpOptions, authScope }); - } - - /** Execute an HTTP request through the shared ARM client. */ - private request( - url: string, - init: RequestInit = {}, - options: ArmRequestOptions = {}, - ): Promise { - return this.http.request(url, init, { ...ApicClient.REQUEST_OPTIONS, ...options }); + this.http = new ArmHttpClient({ ...httpOptions, authScope, requestOptions: ApicClient.REQUEST_OPTIONS }); } async *listResources( @@ -85,9 +76,7 @@ export class ApicClient implements IApicClient { workspace?: string, ): AsyncIterable> { // A missing collection (404) is an empty list, not a failure. - yield* paginateArm(this.http, buildApicListUri(context, type, parentNameParts, workspace), { - requestOptions: ApicClient.REQUEST_OPTIONS, - }); + yield* paginateArm(this.http, buildApicListUri(context, type, parentNameParts, workspace)); } async getResource( @@ -95,7 +84,7 @@ export class ApicClient implements IApicClient { descriptor: ApicResourceDescriptor, ): Promise | undefined> { const url = buildApicArmUri(context, descriptor); - const response = await this.request(url, { method: 'GET' }); + const response = await this.http.request(url, { method: 'GET' }); if (response.status === 404) { await discardBody(response); return undefined; @@ -110,7 +99,7 @@ export class ApicClient implements IApicClient { payload: Record, ): Promise> { const url = buildApicArmUri(context, descriptor); - const response = await this.request(url, { + const response = await this.http.request(url, { method: 'PUT', body: JSON.stringify(payload), }); @@ -138,7 +127,7 @@ export class ApicClient implements IApicClient { descriptor: ApicResourceDescriptor, ): Promise { const url = buildApicArmUri(context, descriptor); - const response = await this.request(url, { method: 'DELETE' }); + const response = await this.http.request(url, { method: 'DELETE' }); // Only the status and headers of a DELETE response are used. await discardBody(response); if (response.status === 404) { @@ -169,7 +158,7 @@ export class ApicClient implements IApicClient { const actionUrl = this.actionUrl(context, descriptor, 'exportSpecification'); let response: Response; try { - response = await this.request(actionUrl, { method: 'POST' }); + response = await this.http.request(actionUrl, { method: 'POST' }); } catch (error) { if (error instanceof HttpError && error.status === 404) { return undefined; @@ -193,7 +182,7 @@ export class ApicClient implements IApicClient { // returns the content directly. let content = body.value; if (body.format === 'link') { - const blob = await this.request(body.value, {}, { skipAuth: true }); + const blob = await this.http.request(body.value, {}, { skipAuth: true }); if (!blob.ok) { await discardBody(blob); logger.warn(`Failed to fetch specification link for ${buildApicLabel(descriptor)}`); @@ -212,7 +201,7 @@ export class ApicClient implements IApicClient { spec: ApicSpecification, ): Promise { const actionUrl = this.actionUrl(context, descriptor, 'importSpecification'); - const response = await this.request(actionUrl, { + const response = await this.http.request(actionUrl, { method: 'POST', body: JSON.stringify({ value: spec.content, @@ -233,7 +222,7 @@ export class ApicClient implements IApicClient { async validatePreFlight(context: ApicServiceContext): Promise { await validateServicePreFlight(context, APIC_PREFLIGHT_KIND, (url) => - this.request(url, { method: 'GET' }), + this.http.request(url, { method: 'GET' }), ); } @@ -265,7 +254,7 @@ export class ApicClient implements IApicClient { context: ApicServiceContext, descriptor: ApicResourceDescriptor, ): Promise { - const response = await this.request(buildApicArmUri(context, descriptor), { method: 'GET' }); + const response = await this.http.request(buildApicArmUri(context, descriptor), { method: 'GET' }); await discardBody(response); return response.status !== 404; } diff --git a/src/clients/apim-client.ts b/src/clients/apim-client.ts index 9325bf6c..bfde52ca 100644 --- a/src/clients/apim-client.ts +++ b/src/clients/apim-client.ts @@ -22,7 +22,7 @@ import { ArmHttpClient, ArmHttpClientOptions, HttpError, - ArmRequestOptions, + ArmClientRequestOptions, AsyncOperation, discardBody, extractAsyncOperation, @@ -105,7 +105,7 @@ export class ApimClient implements IApimClient { * APIM reports operations blocked by an API's in-progress async operation * as a transient 409 PessimisticConcurrencyConflict, which is retried. */ - private static readonly REQUEST_OPTIONS: ArmRequestOptions = { + private static readonly REQUEST_OPTIONS: ArmClientRequestOptions = { isRetryableError: (error) => error.status === 409 && error.code === 'PessimisticConcurrencyConflict', }; @@ -117,21 +117,7 @@ export class ApimClient implements IApimClient { * client. Default to `DefaultAzureCredential` and a `setTimeout` wait. */ constructor(authScope?: string, httpOptions: Omit = {}) { - this.http = new ArmHttpClient({ ...httpOptions, authScope }); - } - - /** - * Execute an HTTP request through the shared ARM client. APIM reports - * operations blocked by an API's in-progress async operation as a - * transient 409 PessimisticConcurrencyConflict, which is retried; other - * client errors are deterministic. - */ - private request( - url: string, - init: RequestInit = {}, - options: ArmRequestOptions = {} - ): Promise { - return this.http.request(url, init, { ...ApimClient.REQUEST_OPTIONS, ...options }); + this.http = new ArmHttpClient({ ...httpOptions, authScope, requestOptions: ApimClient.REQUEST_OPTIONS }); } async *listResources( @@ -175,7 +161,6 @@ export class ApimClient implements IApimClient { // Handle pagination with nextLink yield* paginateArm(this.http, url, { - requestOptions: ApimClient.REQUEST_OPTIONS, shouldStopOnError: (error) => { // HTTP 400 with MethodNotAllowedInPricingTier means the resource type is not // supported by this APIM pricing tier. Skip it — do not treat as a failure. @@ -226,7 +211,7 @@ export class ApimClient implements IApimClient { const suppress500 = isWiki; try { - const response = await this.request(url, { method: 'GET' }, { noRetryOn5xx: suppress500 }); + const response = await this.http.request(url, { method: 'GET' }, { noRetryOn5xx: suppress500 }); if (response.status === 404 || (suppress500 && response.status >= 500 && response.status < 600)) { await discardBody(response); @@ -273,7 +258,7 @@ export class ApimClient implements IApimClient { ): Promise> { const url = buildArmUri(context, descriptor); - const response = await this.request(url, { + const response = await this.http.request(url, { method: 'PUT', body: JSON.stringify(stripSourceArmId(payload)), }); @@ -339,7 +324,7 @@ export class ApimClient implements IApimClient { ): Promise> { const url = buildArmUri(context, descriptor); - const response = await this.request(url, { + const response = await this.http.request(url, { method: 'PATCH', body: JSON.stringify(stripSourceArmId(payload)), }); @@ -383,7 +368,7 @@ export class ApimClient implements IApimClient { for (let attempt = 1; ; attempt++) { try { - const response = await this.request(url, { method: 'DELETE' }); + const response = await this.http.request(url, { method: 'DELETE' }); // Only the status and headers of a DELETE response are used. await discardBody(response); @@ -432,7 +417,7 @@ export class ApimClient implements IApimClient { ): AsyncIterable> { const url = `${context.baseUrl}/apis/${encodeURIComponent(apiName)}/revisions?api-version=${context.apiVersion}`; - const response = await this.request(url); + const response = await this.http.request(url); const data = await response.json() as { value?: unknown[] }; if (data.value && Array.isArray(data.value)) { @@ -475,7 +460,7 @@ export class ApimClient implements IApimClient { try { // Suppress the default 5xx retry loop for XML exports so we can fall // back to the inline export instead of burning retries on a broken path. - const response = await this.request( + const response = await this.http.request( buildExportUrl(exportFormat), {}, { noRetryOn5xx: isXmlExport, allowedNonOkStatuses } @@ -513,7 +498,7 @@ export class ApimClient implements IApimClient { } // Fetch the actual spec content from the blob SAS URL (no auth header needed — SAS token is self-authenticating) - const blobResponse = await this.request(blobLink, {}, { skipAuth: true }); + const blobResponse = await this.http.request(blobLink, {}, { skipAuth: true }); if (!blobResponse.ok) { await discardBody(blobResponse); logger.warn(`Failed to fetch specification blob for ${apiName}: HTTP ${blobResponse.status}`); @@ -548,7 +533,7 @@ export class ApimClient implements IApimClient { apiName: string ): Promise<{ content: string; format: 'wsdl' | 'wadl' } | undefined> { try { - const response = await this.request(url); + const response = await this.http.request(url); if (response.status === 404) { await discardBody(response); return undefined; @@ -645,7 +630,7 @@ export class ApimClient implements IApimClient { async validatePreFlight(context: ApimServiceContext): Promise { await validateServicePreFlight(context, APIM_PREFLIGHT_KIND, (url) => - this.request(url, { method: 'GET' }) + this.http.request(url, { method: 'GET' }) ); } diff --git a/src/clients/arm-http-client.ts b/src/clients/arm-http-client.ts index 61fa4b9c..8261460c 100644 --- a/src/clients/arm-http-client.ts +++ b/src/clients/arm-http-client.ts @@ -355,6 +355,13 @@ export interface ArmHttpClientOptions { credential?: ArmTokenCredential; /** Wait used for retry back-off and LRO polling. Defaults to a `setTimeout` wait. */ delay?: (ms: number) => Promise; + /** + * The service's request options, used by every request this client makes, + * including pagination and LRO polling; a request's own options override + * them. Only options that keep other errors thrown, which pagination and + * polling rely on. + */ + requestOptions?: ArmClientRequestOptions; } /** Per-request behaviour for {@link ArmHttpClient.request}. */ @@ -375,6 +382,9 @@ export interface ArmRequestOptions { isRetryableError?: (error: HttpError) => boolean; } +/** The request options a client can set for all its requests. */ +export type ArmClientRequestOptions = Pick; + /** * Log an intermediate retry. When the retry is attributed to an active * tracking scope (e.g. a publish operation that reports its retry count on @@ -440,6 +450,7 @@ export class ArmHttpClient implements IArmHttpClient { readonly armHosts: readonly string[]; private readonly tokenProvider: () => Promise; private readonly wait: (ms: number) => Promise; + private readonly defaultRequestOptions: ArmRequestOptions; constructor(options: ArmHttpClientOptions = {}) { this.authScope = options.authScope ?? getCloudConfig('public').authScope; @@ -449,6 +460,7 @@ export class ArmHttpClient implements IArmHttpClient { this.authScope, ); this.wait = options.delay ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); + this.defaultRequestOptions = options.requestOptions ?? {}; } /** @@ -503,7 +515,7 @@ export class ArmHttpClient implements IArmHttpClient { allowedNonOkStatuses = [], passthrough404Methods = ['GET'], isRetryableError, - } = options; + } = { ...this.defaultRequestOptions, ...options }; const headers = new Headers(init.headers); if (!skipAuth) { @@ -637,12 +649,6 @@ export class ArmHttpClient implements IArmHttpClient { /* ------------------------------------------------------------------ */ export interface PaginateArmOptions { - /** - * Request options applied to every page request. Options that would return - * an error response instead of throwing it are not accepted, so every - * response other than a 404 is a page. - */ - requestOptions?: Omit; /** * Called when a page request throws. Return true to end the listing * quietly (items already yielded stand); otherwise the error is rethrown. @@ -667,7 +673,7 @@ export async function* paginateArm( while (next) { let response: Response; try { - response = await http.request(next, {}, options.requestOptions); + response = await http.request(next); } catch (error) { if (options.shouldStopOnError?.(error)) { return; @@ -681,8 +687,7 @@ export async function* paginateArm( return; } - // Other non-OK statuses were thrown by the request (requestOptions cannot - // allow them), so this is a page. + // Other non-OK statuses were thrown by the request, so this is a page. const data = (await response.json()) as { value?: unknown[]; nextLink?: string }; if (Array.isArray(data.value)) { diff --git a/tests/unit/clients/apim-client.test.ts b/tests/unit/clients/apim-client.test.ts index 8dc43771..88e5ca4c 100644 --- a/tests/unit/clients/apim-client.test.ts +++ b/tests/unit/clients/apim-client.test.ts @@ -743,6 +743,25 @@ describe('ApimClient.putResource provisioning polling', () => { expect(fetchSpy.mock.calls[1]?.[0]).toBe(operationUrl); }); + it('should retry a PessimisticConcurrencyConflict while polling an async operation', async () => { + const operationUrl = 'https://management.azure.com/operations/update-named-value'; + fetchSpy + .mockResolvedValueOnce(new Response('', { status: 202, headers: { 'Azure-AsyncOperation': operationUrl } })) + .mockResolvedValueOnce( + makeResponse(409, { error: { code: 'PessimisticConcurrencyConflict', message: 'Operation in progress' } }) + ) + .mockResolvedValueOnce(makeResponse(200, { status: 'Succeeded' })) + .mockResolvedValueOnce(makeResponse(200, succeededResource)); + + await expect(client.putResource(testContext, descriptor, { name: 'my-nv' })).resolves.toEqual(succeededResource); + expect(fetchSpy.mock.calls.map((call) => call[0])).toEqual([ + expect.any(String), + operationUrl, + operationUrl, + expect.any(String), + ]); + }); + it('should surface a failed Azure-AsyncOperation when an update returns 200', async () => { const operationUrl = 'https://management.azure.com/operations/update-named-value'; fetchSpy diff --git a/tests/unit/clients/arm-http-client.test.ts b/tests/unit/clients/arm-http-client.test.ts index ec47d415..7385cdc7 100644 --- a/tests/unit/clients/arm-http-client.test.ts +++ b/tests/unit/clients/arm-http-client.test.ts @@ -840,16 +840,17 @@ describe('paginateArm', () => { ).rejects.toMatchObject({ status: 403 }); }); - it('should pass request options to every page request', async () => { + it("should apply the client's request options to every page request", async () => { fetchSpy.mockResolvedValueOnce(json(409, { error: { code: 'Busy' } })).mockResolvedValueOnce( json(200, { value: [] }), ); + const http = new ArmHttpClient({ + credential: cred, + delay: async () => undefined, + requestOptions: { isRetryableError: (e) => e.code === 'Busy' }, + }); - await collect( - paginateArm(client(), URL_1, { - requestOptions: { isRetryableError: (e) => e.code === 'Busy' }, - }), - ); + await collect(paginateArm(http, URL_1)); expect(fetchSpy).toHaveBeenCalledTimes(2); });