diff --git a/docs/ci-cd/authentication-patterns.md b/docs/ci-cd/authentication-patterns.md index 1b1ce973..d951e600 100644 --- a/docs/ci-cd/authentication-patterns.md +++ b/docs/ci-cd/authentication-patterns.md @@ -305,7 +305,31 @@ 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. + +### 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 @@ -315,11 +339,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/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 91f2724e..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 @@ -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/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/clients/apic-client.ts b/src/clients/apic-client.ts index e940b3d5..306dceef 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,166 +18,55 @@ import { buildApicLabel, } from '../lib/apic-uri.js'; import { logger } from '../lib/logger.js'; -import { USER_AGENT } from '../lib/user-agent.js'; +import { errorMessage } from '../lib/error-message.js'; +import { validateServicePreFlight, PreFlightServiceKind } from './preflight.js'; +import { + ArmHttpClient, + ArmHttpClientOptions, + HttpError, + ArmClientRequestOptions, + discardBody, + AsyncOperation, + extractAsyncOperation, + paginateArm, + pollArmOperation, + PollArmOperationOptions, + waitUntilGone, +} from './arm-http-client.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'; - } -} +/** + * @deprecated Use {@link HttpError} from `arm-http-client.js`. Kept for + * existing imports and `instanceof` checks. + */ +export { HttpError as ApicHttpError } from './arm-http-client.js'; -/** Terminal states for an ARM long-running operation. */ -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; - 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; - private static readonly RESOURCE_GROUP_API_VERSION = '2021-04-01'; - - 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: ArmClientRequestOptions = { + 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'); - } 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 }); - - 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); - 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'); - } - - 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; - } + constructor(authScope?: string, httpOptions: Omit = {}) { + this.http = new ArmHttpClient({ ...httpOptions, authScope, requestOptions: ApicClient.REQUEST_OPTIONS }); } async *listResources( @@ -187,32 +75,8 @@ 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 ?? ''; - } + // A missing collection (404) is an empty list, not a failure. + yield* paginateArm(this.http, buildApicListUri(context, type, parentNameParts, workspace)); } async getResource( @@ -220,8 +84,9 @@ 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; } const text = await response.text(); @@ -234,18 +99,23 @@ 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), }); - 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 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 ?? {}; } const text = await response.text(); @@ -257,14 +127,25 @@ 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) { return false; } if (response.status === 202) { - const asyncUrl = this.asyncOperationUrl(response); - if (asyncUrl) { - 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; @@ -277,21 +158,17 @@ 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 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; @@ -305,8 +182,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.http.request(body.value, {}, { skipAuth: true }); if (!blob.ok) { + await discardBody(blob); logger.warn(`Failed to fetch specification link for ${buildApicLabel(descriptor)}`); return undefined; } @@ -323,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, @@ -331,36 +209,21 @@ 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)`); } } } 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.http.request(url, { method: 'GET' }), + ); } /** @@ -382,6 +245,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.http.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, @@ -392,73 +269,19 @@ 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 ( - response.headers.get('Azure-AsyncOperation') ?? - response.headers.get('Location') ?? - undefined - ); - } - /** - * 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 retryAfter = response.headers.get('Retry-After'); - const text = await response.text(); - const status = this.readOperationStatus(text, response.status); - - if (status === TERMINAL_SUCCESS) { - return; - } - if (TERMINAL_FAILURES.has(status)) { - 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); - } - 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 { - if (!text.trim()) { - return httpStatus === 200 ? TERMINAL_SUCCESS : 'InProgress'; - } - try { - const body = JSON.parse(text) as { - status?: string; - properties?: { provisioningState?: string }; - }; - const state = body.status ?? body.properties?.provisioningState; - return state ?? (httpStatus === 200 ? TERMINAL_SUCCESS : 'InProgress'); - } catch { - return httpStatus === 200 ? TERMINAL_SUCCESS : 'InProgress'; - } + 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 06adbf5e..bfde52ca 100644 --- a/src/clients/apim-client.ts +++ b/src/clients/apim-client.ts @@ -2,37 +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 { 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 { validateServicePreFlight, PreFlightServiceKind } from './preflight.js'; +import { withRetryResource } from '../lib/retry-tracker.js'; +import { + ArmHttpClient, + ArmHttpClientOptions, + HttpError, + ArmClientRequestOptions, + 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. @@ -44,6 +47,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 ( @@ -78,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. */ @@ -130,219 +101,23 @@ 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', - ]; - /** 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. + * 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; - } - - /** - * Get bearer token for Azure Management API. - * Uses a promise cache to prevent duplicate token requests under concurrency. - */ - 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; - } - - /** - * 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. - */ - private async request( - url: string, - options: RequestInit = {}, - noRetryOn5xx = false, - skipAuth = false, - allowedNonOkStatuses: readonly number[] = [] - ): 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'); - } + private static readonly REQUEST_OPTIONS: ArmClientRequestOptions = { + isRetryableError: (error) => + error.status === 409 && error.code === 'PessimisticConcurrencyConflict', + }; /** - * 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. + * @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 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)); + constructor(authScope?: string, httpOptions: Omit = {}) { + this.http = new ArmHttpClient({ ...httpOptions, authScope, requestOptions: ApimClient.REQUEST_OPTIONS }); } async *listResources( @@ -385,50 +160,26 @@ 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, { + 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( @@ -460,9 +211,10 @@ export class ApimClient implements IApimClient { const suppress500 = isWiki; try { - const response = await this.request(url, { method: 'GET' }, 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); return undefined; } @@ -485,7 +237,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; } } @@ -506,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)), }); @@ -515,14 +267,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 {}; @@ -566,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)), }); @@ -610,7 +368,9 @@ 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); if (response.status === 404) { return false; // Already deleted @@ -618,13 +378,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); } } @@ -641,11 +399,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; @@ -659,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)) { @@ -702,19 +460,19 @@ 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), {}, - 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.` @@ -723,6 +481,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.` @@ -739,8 +498,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.http.request(blobLink, {}, { skipAuth: true }); if (!blobResponse.ok) { + await discardBody(blobResponse); logger.warn(`Failed to fetch specification blob for ${apiName}: HTTP ${blobResponse.status}`); return undefined; } @@ -755,7 +515,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; } } @@ -773,14 +533,9 @@ 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) { - 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; } @@ -822,7 +577,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; } @@ -874,58 +629,9 @@ export class ApimClient implements IApimClient { } async validatePreFlight(context: ApimServiceContext): Promise { - // Check resource group exists - 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; - try { - rgResponse = await this.request(rgUrl, { method: 'GET' }); - } catch (error) { - 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) { - 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.http.request(url, { method: 'GET' }) + ); } private detectSpecificationFormat( @@ -952,159 +658,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 @@ -1116,21 +714,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..8261460c --- /dev/null +++ b/src/clients/arm-http-client.ts @@ -0,0 +1,1004 @@ +// 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 { 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'; +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, '\\$&'); +} + +/** + * 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, 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 + * 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; + // 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. + } + } + // 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.replace(SIG_PARAMETER, `$1$2${MASKED_SIGNATURE}`); +} + +/** + * 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; + /** + * 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}. */ +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; +} + +/** 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 + * 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; + private readonly defaultRequestOptions: ArmRequestOptions; + + 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))); + this.defaultRequestOptions = options.requestOptions ?? {}; + } + + /** + * 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, + } = { ...this.defaultRequestOptions, ...options }; + + const headers = new Headers(init.headers); + if (!skipAuth) { + 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) { + 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 }); + + // 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; + } + // 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) { + // 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( + `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 { + /** + * 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); + } 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, 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/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/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 new file mode 100644 index 00000000..1fbb0fdf --- /dev/null +++ b/src/clients/preflight.ts @@ -0,0 +1,136 @@ +// 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'; +import { HttpError, discardBody } from './arm-http-client.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 {@link HttpError} 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); + // Only the status is used. + await discardBody(rgResponse); + } 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); + } + // 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 + // 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; +} + +/** Status code of an {@link HttpError} (thrown by both clients), else undefined. */ +function getHttpStatus(error: unknown): number | undefined { + return error instanceof HttpError ? error.status : undefined; +} diff --git a/src/lib/cloud-config.ts b/src/lib/cloud-config.ts index 40eb437b..e3793640 100644 --- a/src/lib/cloud-config.ts +++ b/src/lib/cloud-config.ts @@ -77,6 +77,28 @@ 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); +} + +/** + * 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..fc8a844d --- /dev/null +++ b/src/lib/error-message.ts @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +/** + * Message text of a caught value for log and error output. + */ + +/** + * 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 { + 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 new file mode 100644 index 00000000..4efe0b13 --- /dev/null +++ b/src/lib/proxy.ts @@ -0,0 +1,156 @@ +// 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 { errorCauses } from './error-message.js'; +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()]); + }, + }; +} + +/** + * 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' } + /** 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/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..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 */ @@ -27,9 +30,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,43 +63,69 @@ 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; } +/** 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; } -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, - }; +/** + * 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; + } +} + +/** 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) }; } /* ------------------------------------------------------------------ */ @@ -111,20 +137,21 @@ async function defaultArmRequest(url: string, token: string): Promise { /* ---- 1. Parse vault name & secret name from the URI ---- */ let vaultName: string; @@ -145,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(ARM_SCOPE)).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; } @@ -161,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 @@ -172,10 +201,10 @@ export async function checkKeyVaultSecretAccess( /* ---- 4. Locate the vault in the same subscription ---- */ const vault = await findVaultInSubscription( + apimContext.armBaseUrl, apimContext.subscriptionId, vaultName, - token, - armRequest, + armClient, ); if (!vault) return; // soft-skip — warning already logged @@ -184,15 +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, @@ -225,22 +255,21 @@ 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}'` : 'system-assigned identity'; const apimUrl = - `https://management.azure.com/subscriptions/${ctx.subscriptionId}` + + `${ctx.armBaseUrl}/subscriptions/${ctx.subscriptionId}` + `/resourceGroups/${ctx.resourceGroup}` + `/providers/Microsoft.ApiManagement/service/${ctx.serviceName}` + `?api-version=${APIM_API_VERSION}`; 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}) — ` + @@ -252,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 }; } @@ -306,21 +335,21 @@ async function resolveApimPrincipal( * vault is not found (e.g. it lives in a different subscription). */ 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}'`, ); const listUrl = - `https://management.azure.com/subscriptions/${subscriptionId}` + + `${armBaseUrl}/subscriptions/${subscriptionId}` + `/resources?$filter=${filter}&api-version=${RESOURCES_API_VERSION}`; 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}) — ` + @@ -333,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; } @@ -348,11 +377,11 @@ async function findVaultInSubscription( // GET the vault resource to read properties (RBAC mode, access policies) const vaultUrl = - `https://management.azure.com${vaultResourceId}` + + `${armBaseUrl}${vaultResourceId}` + `?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}) — ` + @@ -365,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; } @@ -387,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. @@ -394,29 +426,29 @@ const SECRET_GET_WILDCARDS = [ * for the `Microsoft.KeyVault/vaults/secrets/getSecret/action` data-action. */ async function checkRbacAccess( + armBaseUrl: string, vaultResourceId: string, principalId: string, identityLabel: string, vaultName: string, secretName: string, - token: string, - armRequest: ArmRequestFn, -): Promise { + armClient: IArmHttpClient, +): 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}`; 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>; @@ -425,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) { @@ -449,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 --- @@ -465,9 +497,9 @@ async function checkRbacAccess( if (!roleDefId) continue; const hasAccess = await roleDefinitionGrantsSecretGet( - roleDefId, token, armRequest, + armBaseUrl, roleDefId, armClient, ); - if (hasAccess) return; // confirmed via dataActions + if (hasAccess) return true; // confirmed via dataActions } throw new KeyVaultAccessError( @@ -487,17 +519,17 @@ 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, + armClient: IArmHttpClient, ): Promise { const url = - `https://management.azure.com${roleDefinitionId}` + + `${armBaseUrl}${roleDefinitionId}` + `?api-version=${AUTHZ_API_VERSION}`; 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/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/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/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/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/clients/apic-client.test.ts b/tests/unit/clients/apic-client.test.ts new file mode 100644 index 00000000..7d28f2df --- /dev/null +++ b/tests/unit/clients/apic-client.test.ts @@ -0,0 +1,1057 @@ +// 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. + * - 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 { 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', async (importOriginal) => ({ + // Keep the real getBearerTokenProvider (the SDK token cycler) around the mocked credential. + ...(await importOriginal()), + DefaultAzureCredential: class { + getToken = mockGetToken; + }, +})); + +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(() => { + mockGetToken.mockResolvedValue({ token: 'fake-token', expiresOnTimestamp: Date.now() + 60 * 60 * 1000 }); + client = new ApicClient(); + 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 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 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.", + ); + }); +}); + +/* ------------------------------------------------------------------ */ +/* 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('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, {})); + + await settle(scoped.getResource(testContext, apiDescriptor)); + + expect(mockGetToken).toHaveBeenCalledWith('https://management.usgovcloudapi.net/.default'); + }); + }); + + /* ------------------------------------------------------------------ */ + /* listResources */ + /* ------------------------------------------------------------------ */ + + describe('ApicClient.listResources', () => { + it('should list 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('should list 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('should list 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('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 + .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('should return 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('should tolerate 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('should throw 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('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)); + + 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('should build 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('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('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({}); + }); + }); + + /* ------------------------------------------------------------------ */ + /* putResource */ + /* ------------------------------------------------------------------ */ + + describe('ApicClient.putResource', () => { + const payload = { properties: { title: 'Pets', kind: 'rest' } }; + + 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)); + + 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('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('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' })) + .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('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 })) + .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('should throw 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('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( + (e: unknown) => e, + ); + + expect(error).toBeInstanceOf(ApicHttpError); + expect(error).toMatchObject({ status: 409, code: undefined }); + }); + }); + + /* ------------------------------------------------------------------ */ + /* deleteResource */ + /* ------------------------------------------------------------------ */ + + 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); + + 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('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('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' })) + .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('should treat 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('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('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)); + + 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('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 })) + .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('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 })) + .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('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 })) + .mockResolvedValueOnce(new Response('', { status: 404 })); + + await expect( + settle(client.exportSpecification(testContext, definitionDescriptor)), + ).resolves.toBeUndefined(); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + 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' } })); + + const spec = await settle(client.exportSpecification(testContext, definitionDescriptor)); + + expect(spec).toEqual({ content: '{}', name: 'openapi', version: undefined }); + }); + + it('should return 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('should warn and return 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' })], + ])('should return undefined for %s', async (_label, response) => { + fetchSpy.mockResolvedValueOnce(response); + + await expect( + settle(client.exportSpecification(testContext, definitionDescriptor)), + ).resolves.toBeUndefined(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + }); + + /* ------------------------------------------------------------------ */ + /* importSpecification */ + /* ------------------------------------------------------------------ */ + + describe('ApicClient.importSpecification', () => { + const IMPORT_URL = `${DEFINITION_PATH}/importSpecification?api-version=${API_VERSION}`; + + 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( + 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('should omit 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('should poll 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('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( + 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('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 })) + .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('should not retry a 400', async () => { + fetchSpy.mockResolvedValueOnce(json(400, { error: { code: 'Nope' } })); + + await expect( + settle(client.putResource(testContext, apiDescriptor, {})), + ).rejects.toMatchObject({ status: 400, code: 'Nope' }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + }); + + /* ------------------------------------------------------------------ */ + /* Long-running operation polling */ + /* ------------------------------------------------------------------ */ + + describe('ApicClient long-running operation polling', () => { + function acceptedPut(): void { + fetchSpy.mockResolvedValueOnce(empty(202, { 'Azure-AsyncOperation': OPERATION_URL })); + } + + it('should throw with the label and error message when the operation fails', async () => { + acceptedPut(); + fetchSpy.mockResolvedValueOnce( + json(200, { status: 'Failed', 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(/failed/i); + expect(error.message).toContain('workspaces/default/apis/pet store'); + expect(error.message).toContain('Spec is invalid'); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it('should time 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); + }); + + it('should poll 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('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( + settle(client.putResource(testContext, apiDescriptor, {})), + ).resolves.toEqual({ name: 'pet store' }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + 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' } })) + .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('should release the unread body of each resource GET while waiting for a delete', async () => { + const cancel = vi.fn(); + fetchSpy + .mockResolvedValueOnce(empty(202)) + .mockResolvedValueOnce(new Response(new ReadableStream({ cancel }), { status: 200 })) + .mockResolvedValueOnce(new Response(new ReadableStream({ cancel }), { status: 404 })); + + await expect(settle(client.deleteResource(testContext, apiDescriptor))).resolves.toBe(true); + + expect(cancel).toHaveBeenCalledTimes(2); + }); + + it('should time out a DELETE without a polling header when the resource never disappears', async () => { + fetchSpy + .mockResolvedValueOnce(empty(202)) + .mockImplementation(async () => json(200, { name: 'pet store' })); + const start = Date.now(); + + 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('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(recording.deleteResource(testContext, apiDescriptor)).resolves.toBe(true); + expect(delays).toEqual([5000, 5000, 5000, 5000]); + }); + }); + + /* ------------------------------------------------------------------ */ + /* ARM host validation */ + /* ------------------------------------------------------------------ */ + + 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': 'https://attacker.example.com/operations/op-1' }), + ); + + 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('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)); + + await expect(settle(client.deleteResource(testContext, apiDescriptor))).resolves.toBe(true); + expect(urls()).toEqual([API_URL, API_URL]); + }); + + it.each([ + 'https://management.azure.com/operations/op-1', + 'https://eastus.management.azure.com/operations/op-1', + ])('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' })); + + await expect(settle(client.deleteResource(testContext, apiDescriptor))).resolves.toBe(true); + expect(urls()).toEqual([API_URL, operationUrl]); + }); + + 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, { 'Azure-AsyncOperation': operationUrl })) + .mockResolvedValueOnce(empty(404)); + + await expect(settle(client.deleteResource(testContext, apiDescriptor))).resolves.toBe(true); + expect(urls()).toEqual([API_URL, API_URL]); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Ignoring')); + }); + + 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': usgovOperation })) + .mockResolvedValueOnce(json(200, { status: 'Succeeded' })) + .mockResolvedValueOnce(json(201, { name: 'pet store' }, { 'Azure-AsyncOperation': 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('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( + (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/clients/apim-client.test.ts b/tests/unit/clients/apim-client.test.ts index d09cb2b2..88e5ca4c 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 = { @@ -72,6 +79,66 @@ 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(undefined, { credential: fakeCredential }); + fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + }); + + 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 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.", + ); + }); + }); }); describe('ApimClient.listResources', () => { @@ -79,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(() => { @@ -334,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(() => { @@ -443,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( @@ -461,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. @@ -531,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(() => { @@ -550,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)); @@ -612,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 @@ -677,6 +827,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 @@ -807,23 +995,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 @@ -855,6 +1049,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', () => { @@ -862,14 +1125,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(() => { @@ -1006,15 +1264,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(() => { @@ -1045,41 +1298,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( @@ -1109,14 +1327,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(() => { @@ -1175,15 +1388,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(() => { @@ -1385,6 +1593,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" @@ -1621,12 +1870,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(() => { @@ -1674,38 +1920,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(() => { @@ -1760,3 +1984,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..7385cdc7 --- /dev/null +++ b/tests/unit/clients/arm-http-client.test.ts @@ -0,0 +1,1342 @@ +// 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 (***)'], + ['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"`, + '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 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`; + + 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 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. + 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 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')); + 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 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(http, URL_1)); + + 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 b6b0b49d..7facfe80 100644 --- a/tests/unit/lib/cloud-config.test.ts +++ b/tests/unit/lib/cloud-config.test.ts @@ -8,6 +8,8 @@ import { describe, it, expect } from 'vitest'; import { getCloudConfig, buildArmBaseUrl, + getArmHosts, + getArmHostsForAuthScope, } from '../../../src/lib/cloud-config.js'; describe('getCloudConfig', () => { @@ -147,3 +149,27 @@ 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([ + 'management.azure.com', + 'management.chinacloudapi.cn', + 'management.usgovcloudapi.net', + 'management.microsoftazure.de', + ]); + }); +}); diff --git a/tests/unit/lib/error-message.test.ts b/tests/unit/lib/error-message.test.ts new file mode 100644 index 00000000..f3853234 --- /dev/null +++ b/tests/unit/lib/error-message.test.ts @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +/** + * Unit tests for errorMessage + */ + +import { describe, it, expect } from 'vitest'; +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]'], + ])('should convert %s to a string', (_label, value, expected) => { + expect(errorMessage(value)).toBe(expected); + }); +}); diff --git a/tests/unit/lib/proxy.test.ts b/tests/unit/lib/proxy.test.ts new file mode 100644 index 00000000..e31b196e --- /dev/null +++ b/tests/unit/lib/proxy.test.ts @@ -0,0 +1,237 @@ +// 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, + isProxyAuthenticationError, + 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'); + }); +}); + +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); + }); +}); 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/keyvault-checker.test.ts b/tests/unit/services/keyvault-checker.test.ts index 4efb33b8..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'; @@ -23,6 +41,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 */ @@ -83,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(); }); /* ------------------------------------------------------------------ */ @@ -96,7 +123,7 @@ describe('checkKeyVaultSecretAccess', () => { await expect( checkKeyVaultSecretAccess( 'not-a-url', undefined, apimContext, - mockTokenProviderFactory, mockArmRequest, + mockArmClientFactory, ), ).rejects.toMatchObject({ name: 'KeyVaultAccessError', @@ -108,7 +135,7 @@ describe('checkKeyVaultSecretAccess', () => { await expect( checkKeyVaultSecretAccess( 'https://myvault.vault.azure.net/', undefined, apimContext, - mockTokenProviderFactory, mockArmRequest, + mockArmClientFactory, ), ).rejects.toMatchObject({ name: 'KeyVaultAccessError', @@ -126,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 */ /* ------------------------------------------------------------------ */ @@ -143,7 +186,7 @@ describe('checkKeyVaultSecretAccess', () => { await expect( checkKeyVaultSecretAccess( validSecretIdentifier, undefined, apimContext, - mockTokenProviderFactory, mockArmRequest, + mockArmClientFactory, ), ).rejects.toMatchObject({ name: 'KeyVaultAccessError', @@ -159,7 +202,7 @@ describe('checkKeyVaultSecretAccess', () => { await expect( checkKeyVaultSecretAccess( validSecretIdentifier, undefined, apimContext, - mockTokenProviderFactory, mockArmRequest, + mockArmClientFactory, ), ).rejects.toMatchObject({ name: 'KeyVaultAccessError', @@ -175,7 +218,7 @@ describe('checkKeyVaultSecretAccess', () => { await expect( checkKeyVaultSecretAccess( validSecretIdentifier, 'missing-client-id', apimContext, - mockTokenProviderFactory, mockArmRequest, + mockArmClientFactory, ), ).rejects.toMatchObject({ name: 'KeyVaultAccessError', @@ -189,7 +232,7 @@ describe('checkKeyVaultSecretAccess', () => { await expect( checkKeyVaultSecretAccess( validSecretIdentifier, undefined, apimContext, - mockTokenProviderFactory, mockArmRequest, + mockArmClientFactory, ), ).resolves.toBeUndefined(); }); @@ -200,7 +243,7 @@ describe('checkKeyVaultSecretAccess', () => { await expect( checkKeyVaultSecretAccess( validSecretIdentifier, undefined, apimContext, - mockTokenProviderFactory, mockArmRequest, + mockArmClientFactory, ), ).resolves.toBeUndefined(); }); @@ -217,7 +260,7 @@ describe('checkKeyVaultSecretAccess', () => { await expect( checkKeyVaultSecretAccess( validSecretIdentifier, undefined, apimContext, - mockTokenProviderFactory, mockArmRequest, + mockArmClientFactory, ), ).resolves.toBeUndefined(); }); @@ -230,7 +273,7 @@ describe('checkKeyVaultSecretAccess', () => { await expect( checkKeyVaultSecretAccess( validSecretIdentifier, undefined, apimContext, - mockTokenProviderFactory, mockArmRequest, + mockArmClientFactory, ), ).resolves.toBeUndefined(); }); @@ -244,7 +287,7 @@ describe('checkKeyVaultSecretAccess', () => { await expect( checkKeyVaultSecretAccess( validSecretIdentifier, undefined, apimContext, - mockTokenProviderFactory, mockArmRequest, + mockArmClientFactory, ), ).resolves.toBeUndefined(); }); @@ -266,7 +309,7 @@ describe('checkKeyVaultSecretAccess', () => { await expect( checkKeyVaultSecretAccess( validSecretIdentifier, undefined, apimContext, - mockTokenProviderFactory, mockArmRequest, + mockArmClientFactory, ), ).resolves.toBeUndefined(); }); @@ -283,7 +326,7 @@ describe('checkKeyVaultSecretAccess', () => { await expect( checkKeyVaultSecretAccess( validSecretIdentifier, undefined, apimContext, - mockTokenProviderFactory, mockArmRequest, + mockArmClientFactory, ), ).resolves.toBeUndefined(); }); @@ -298,7 +341,7 @@ describe('checkKeyVaultSecretAccess', () => { await expect( checkKeyVaultSecretAccess( validSecretIdentifier, undefined, apimContext, - mockTokenProviderFactory, mockArmRequest, + mockArmClientFactory, ), ).rejects.toMatchObject({ name: 'KeyVaultAccessError', @@ -306,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'; @@ -331,7 +412,7 @@ describe('checkKeyVaultSecretAccess', () => { await expect( checkKeyVaultSecretAccess( validSecretIdentifier, undefined, apimContext, - mockTokenProviderFactory, mockArmRequest, + mockArmClientFactory, ), ).resolves.toBeUndefined(); }); @@ -359,7 +440,7 @@ describe('checkKeyVaultSecretAccess', () => { await expect( checkKeyVaultSecretAccess( validSecretIdentifier, undefined, apimContext, - mockTokenProviderFactory, mockArmRequest, + mockArmClientFactory, ), ).resolves.toBeUndefined(); }); @@ -387,7 +468,7 @@ describe('checkKeyVaultSecretAccess', () => { await expect( checkKeyVaultSecretAccess( validSecretIdentifier, undefined, apimContext, - mockTokenProviderFactory, mockArmRequest, + mockArmClientFactory, ), ).rejects.toMatchObject({ name: 'KeyVaultAccessError', @@ -419,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'))) @@ -436,7 +575,7 @@ describe('checkKeyVaultSecretAccess', () => { await expect( checkKeyVaultSecretAccess( validSecretIdentifier, undefined, apimContext, - mockTokenProviderFactory, mockArmRequest, + mockArmClientFactory, ), ).resolves.toBeUndefined(); }); @@ -459,7 +598,7 @@ describe('checkKeyVaultSecretAccess', () => { await expect( checkKeyVaultSecretAccess( validSecretIdentifier, undefined, apimContext, - mockTokenProviderFactory, mockArmRequest, + mockArmClientFactory, ), ).resolves.toBeUndefined(); }); @@ -478,7 +617,7 @@ describe('checkKeyVaultSecretAccess', () => { await expect( checkKeyVaultSecretAccess( validSecretIdentifier, undefined, apimContext, - mockTokenProviderFactory, mockArmRequest, + mockArmClientFactory, ), ).resolves.toBeUndefined(); }); @@ -497,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', @@ -519,7 +675,7 @@ describe('checkKeyVaultSecretAccess', () => { await expect( checkKeyVaultSecretAccess( validSecretIdentifier, undefined, apimContext, - mockTokenProviderFactory, mockArmRequest, + mockArmClientFactory, ), ).rejects.toMatchObject({ name: 'KeyVaultAccessError', @@ -527,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 */ /* ------------------------------------------------------------------ */ @@ -543,7 +721,7 @@ describe('checkKeyVaultSecretAccess', () => { await expect( checkKeyVaultSecretAccess( validSecretIdentifier, 'ua-client', apimContext, - mockTokenProviderFactory, mockArmRequest, + mockArmClientFactory, ), ).resolves.toBeUndefined(); @@ -570,19 +748,163 @@ describe('checkKeyVaultSecretAccess', () => { 'https://myvault.vault.azure.net/secrets/my-secret/abc123version', undefined, apimContext, - mockTokenProviderFactory, - mockArmRequest, + mockArmClientFactory, ), ).resolves.toBeUndefined(); }); }); -describe('defaultArmRequest User-Agent header', () => { +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('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'] }], + }, + })); + const armClientFactory = vi.fn(() => ({ getToken, request: armRequest })); + + await expect( + checkKeyVaultSecretAccess( + secretIdentifier, + undefined, + { + subscriptionId: 'sub-1', + resourceGroup: 'rg-1', + serviceName: 'apim-1', + armBaseUrl, + authScope, + }, + armClientFactory, + ), + ).resolves.toBeUndefined(); + + expect(armClientFactory).toHaveBeenCalledWith(authScope); + expect(getToken).toHaveBeenCalled(); + + 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('fake-arm-token'), request: armRequest }), + ), + ).resolves.toBeUndefined(); + + const vaultSearchUrl = armRequest.mock.calls[1][0] as string; + expect(decodeURIComponent(vaultSearchUrl)).toContain("name eq 'myvault'"); + }); +}); + +describe('checkKeyVaultSecretAccess through ArmHttpClient', () => { + 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; + 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(() => { @@ -590,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, - { subscriptionId: 'sub-1', resourceGroup: 'rg-1', serviceName: 'apim-1' }, - 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/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 6bcac061..8ba748fe 100644 --- a/tests/unit/services/publish-service.test.ts +++ b/tests/unit/services/publish-service.test.ts @@ -4,13 +4,24 @@ * 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 { 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'); @@ -112,6 +123,7 @@ describe('publish-service', () => { const store = createMockStore(resources); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -160,6 +172,7 @@ describe('publish-service', () => { const store = createMockStore(resources); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -787,6 +800,7 @@ describe('publish-service', () => { const store = createMockStore(resources); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -810,6 +824,7 @@ describe('publish-service', () => { const store = createMockStore(resources); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -831,6 +846,7 @@ describe('publish-service', () => { const store = createMockStore(resources); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -871,6 +887,7 @@ describe('publish-service', () => { }); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -905,6 +922,7 @@ describe('publish-service', () => { }); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -945,6 +963,7 @@ describe('publish-service', () => { }); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -975,6 +994,7 @@ describe('publish-service', () => { }); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -1006,6 +1026,7 @@ describe('publish-service', () => { const store = createMockStore(resources); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: true, @@ -1068,6 +1089,7 @@ describe('publish-service', () => { ); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -1097,6 +1119,7 @@ describe('publish-service', () => { }); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: true, @@ -1125,6 +1148,7 @@ describe('publish-service', () => { }); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -1152,6 +1176,7 @@ describe('publish-service', () => { }); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -1282,6 +1307,7 @@ describe('publish-service', () => { }); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -1320,6 +1346,7 @@ describe('publish-service', () => { }); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: true, @@ -1353,6 +1380,7 @@ describe('publish-service', () => { ]); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -1377,6 +1405,7 @@ describe('publish-service', () => { const client = createMockClient(); const store = createMockStore([]); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -1411,6 +1440,7 @@ describe('publish-service', () => { ]); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -1445,6 +1475,7 @@ describe('publish-service', () => { const store = createMockStore(resources); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -1469,6 +1500,7 @@ describe('publish-service', () => { const store = createMockStore(resources); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -1510,6 +1542,7 @@ describe('publish-service', () => { ); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -1532,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, @@ -1552,6 +1586,7 @@ describe('publish-service', () => { const store = createMockStore([]); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -1573,6 +1608,7 @@ describe('publish-service', () => { const store = createMockStore([]); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -1594,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, @@ -1605,6 +1642,64 @@ 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 = { + cloud: getCloudConfig('public'), + 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(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) => { + 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(), + ); + }); + }); }); describe('named value ordering within tier 1', () => { @@ -1631,6 +1726,7 @@ describe('publish-service', () => { ); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -1684,6 +1780,7 @@ describe('publish-service', () => { ); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -1746,6 +1843,7 @@ describe('publish-service', () => { ); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -1780,6 +1878,7 @@ describe('publish-service', () => { }); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -1819,6 +1918,7 @@ describe('publish-service', () => { ); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -1854,6 +1954,7 @@ describe('publish-service', () => { ); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -1878,6 +1979,7 @@ describe('publish-service', () => { const store = createMockStore(resources); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -2013,6 +2115,7 @@ describe('publish-service', () => { const store = createMockStore(resources); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -2053,6 +2156,7 @@ describe('publish-service', () => { ); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -2091,6 +2195,7 @@ describe('publish-service', () => { ); const config: PublishConfig = { + cloud: getCloudConfig('public'), service: testContext, sourceDir: '/source', dryRun: false, @@ -2199,6 +2304,7 @@ describe('publish-service', () => { describe('publish output readability', () => { const baseConfig: PublishConfig = { service: testContext, + cloud: getCloudConfig('public'), sourceDir: '/source', dryRun: false, deleteUnmatched: false, @@ -2267,6 +2373,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 = { 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/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', + } ); }); 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,