diff --git a/package-lock.json b/package-lock.json index 69c2bd16..e478b8c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2110,9 +2110,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "dev": true, "funding": [ { diff --git a/src/clients/apim-client.ts b/src/clients/apim-client.ts index 5d9eaf36..f1d85bde 100644 --- a/src/clients/apim-client.ts +++ b/src/clients/apim-client.ts @@ -207,9 +207,18 @@ export class ApimClient implements IApimClient { return response; } catch (error) { - // Do not retry client errors (4xx) — they are deterministic, not transient. - // 429 rate-limiting is already handled above and never reaches here. - if (error instanceof HttpError && error.status >= 400 && error.status < 500) { + // 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) { @@ -454,7 +463,18 @@ export class ApimClient implements IApimClient { context: ApimServiceContext, descriptor: ResourceDescriptor ): Promise { - const url = buildArmUri(context, descriptor); + let url = buildArmUri(context, descriptor); + + // Deleting an API that has revisions requires deleteRevisions=true; without it + // APIM refuses the base (current-revision) delete with "Cannot delete the + // current revision of an API." This also removes all revisions in one call, + // so callers skip the individual ;rev=N deletes. + if ( + descriptor.type === ResourceType.Api && + !(descriptor.nameParts[0] ?? '').includes(';rev=') + ) { + url += '&deleteRevisions=true'; + } for (let attempt = 1; ; attempt++) { try { @@ -482,6 +502,15 @@ export class ApimClient implements IApimClient { if (message.includes('404')) { return false; } + // Resource is still referenced by another entity (e.g. a policy fragment + // used by the service policy). It cannot be deleted until the reference is + // removed; skip it with a warning instead of failing the whole prune. + if (message.includes('is used by the following entities')) { + logger.warn( + `Skipping delete of ${buildResourceLabel(descriptor)}: still referenced by another entity` + ); + return false; + } // Transient optimistic-concurrency conflict: cascade deletes of related // resources (subscriptions, product/gateway associations) can modify // the resource while its async DELETE is in flight. Retry the DELETE. diff --git a/src/lib/resource-path.ts b/src/lib/resource-path.ts index 0dc9835d..6b2774ba 100644 --- a/src/lib/resource-path.ts +++ b/src/lib/resource-path.ts @@ -143,6 +143,16 @@ export function getNamePart(nameParts: string[], index: number): string { return value; } +/** True when an API name carries a revision suffix (e.g. "my-api;rev=2"). */ +export function isApiRevisionName(apiName: string): boolean { + return apiName.includes(';rev='); +} + +/** Root API name with any ";rev=N" suffix stripped. */ +export function getApiRootName(apiName: string): string { + return apiName.split(';rev=')[0] ?? apiName; +} + /** * Converts a positional template string to a capturing regex. * Each `{i}` placeholder becomes a `([^/]+)` capture group; all other diff --git a/src/services/api-extractor.ts b/src/services/api-extractor.ts index 7d45266a..98b72d0b 100644 --- a/src/services/api-extractor.ts +++ b/src/services/api-extractor.ts @@ -200,8 +200,10 @@ async function extractApiRevisions( for await (const revision of revisions) { try { const revNumber = (revision.apiRevision ?? revision.revisionNumber) as string | undefined; - if (!revNumber || revNumber === '1') { - // Skip revision 1 — it's the main API + // Skip the current revision — it is represented by the main API folder. + // Using isCurrent (not a hard-coded '1') correctly handles APIs whose + // current revision is not revision 1. + if (!revNumber || revision.isCurrent === true) { continue; } diff --git a/src/services/api-publisher.ts b/src/services/api-publisher.ts index b252a6b1..5743887f 100644 --- a/src/services/api-publisher.ts +++ b/src/services/api-publisher.ts @@ -14,12 +14,15 @@ import type { PublishConfig } from '../models/config.js'; import * as yaml from 'js-yaml'; import { ResourceType } from '../models/resource-types.js'; import { + normalizeApiAuthenticationSettings, normalizeMcpToolOperationIds, + prefersLegacyAuthOverride, publishResource, type ResourcePublishResult, } from './resource-publisher.js'; import { runParallel } from '../lib/parallel-runner.js'; import { applyOverrides } from './override-merger.js'; +import { mapDescriptor } from './env-mapper.js'; import { logger } from '../lib/logger.js'; import { getNamePart, getPublishTier } from '../lib/resource-path.js'; import { isAutoGeneratedId } from '../lib/auto-generated.js'; @@ -54,8 +57,26 @@ export async function publishApi( config: PublishConfig ): Promise { try { - // Step 1: Publish root API (with spec import if available) - const rootResult = await publishRootApi(client, store, context, descriptor, config); + // Deployed (env-mapped) base descriptor — derived once and used for every + // direct APIM call so canonical and affixed names can never diverge. + const deployedDescriptor = config.envMapping + ? mapDescriptor(descriptor, config.envMapping) + : descriptor; + + // Step 1: Publish root API (with spec import if available). + // On a fresh target the root is created at its source revision number so + // it cannot collide with ;rev=N revision artifacts. + const putDescriptor = await resolveRootApiPutDescriptor( + client, + store, + context, + descriptor, + deployedDescriptor, + config + ); + const rootResult = await publishRootApi(client, store, context, descriptor, config, { + putDescriptor, + }); if (rootResult.status !== 'success') { return rootResult; } @@ -64,7 +85,7 @@ export async function publishApi( await alignImportedOperationDescriptions( client, context, - descriptor, + deployedDescriptor, rootResult.operationIdsWithNullDescription ); } @@ -75,13 +96,10 @@ export async function publishApi( // Step 2b: Align root API only when source marks it as current. // Source of truth is properties.isCurrent in root apiInformation.json. if (publishedRevisionCount > 0 && rootResult.isCurrent === true) { - const alignResult = await alignActiveRevisionWithSource( - client, - store, - context, - descriptor, - config - ); + const alignResult = await publishRootApi(client, store, context, descriptor, config, { + includeSpecification: false, + putDescriptor: deployedDescriptor, + }); if (alignResult.status !== 'success') { return alignResult; } @@ -148,6 +166,49 @@ interface RootApiResult { interface PublishRootApiOptions { includeSpecification?: boolean; + /** Descriptor to PUT to (defaults to the artifact descriptor). Lets the root + * API be created at apis/{name};rev=N while still reading apis/{name} artifacts. */ + putDescriptor?: ResourceDescriptor; +} + +/** + * A plain root PUT creates a brand-new API as revision 1, which collides with + * a ;rev=1 revision artifact and silently absorbs it whenever the source's + * current revision number is > 1. When the API does not yet exist on the + * target, PUT the root at its true revision number (apis/{name};rev=N) instead. + * Existing APIs keep the plain root PUT — their current revision cannot be + * renumbered. + * + * Reads artifacts via the canonical descriptor; all APIM lookups and the + * returned PUT target use the deployed (env-mapped) descriptor, with ;rev=N + * appended after affixing so suffix mappings cannot corrupt it. + */ +async function resolveRootApiPutDescriptor( + client: IApimClient, + store: IArtifactStore, + context: ApimServiceContext, + descriptor: ResourceDescriptor, + deployedDescriptor: ResourceDescriptor, + config: PublishConfig +): Promise { + const json = await store.readResource(config.sourceDir, descriptor); + const rev = (json?.properties as Record | undefined)?.apiRevision; + if (typeof rev !== 'string' || rev === '' || rev === '1') { + return deployedDescriptor; + } + + const existing = await client.getResource(context, deployedDescriptor); + if (existing) { + return deployedDescriptor; + } + + return { + ...deployedDescriptor, + nameParts: [ + `${getNamePart(deployedDescriptor.nameParts, 0)};rev=${rev}`, + ...deployedDescriptor.nameParts.slice(1), + ], + }; } /** @@ -176,11 +237,18 @@ async function publishRootApi( // Root APIs publish through api-publisher rather than publishResource, so // they need the same pre-override MCP tool normalization here that revision - // APIs receive in resource-publisher. - json = normalizeMcpToolOperationIds(json, context); + // APIs receive in resource-publisher — including env-mapped API names so the + // tool operationIds match the affixed API this PUT targets. + json = normalizeMcpToolOperationIds(json, context, config.envMapping); // Apply overrides json = applyOverrides(descriptor, json, config.overrides); + json = normalizeApiAuthenticationSettings(json, { + preferLegacyFields: prefersLegacyAuthOverride( + getNamePart(descriptor.nameParts, 0), + config.overrides?.apis + ), + }); const isCurrent = getApiIsCurrent(json); // Try to read the specification file for this API @@ -239,7 +307,7 @@ async function publishRootApi( } // PUT the API resource to APIM - await client.putResource(context, descriptor, json); + await client.putResource(context, options?.putDescriptor ?? descriptor, json); return { descriptor, @@ -251,22 +319,6 @@ async function publishRootApi( }; } -async function alignActiveRevisionWithSource( - client: IApimClient, - store: IArtifactStore, - context: ApimServiceContext, - descriptor: ResourceDescriptor, - config: PublishConfig -): Promise { - logger.debug( - `Source marks "${getNamePart(descriptor.nameParts, 0)}" as current; re-applying root metadata to align active revision` - ); - - return publishRootApi(client, store, context, descriptor, config, { - includeSpecification: false, - }); -} - /** * Find and publish API revisions in numeric order */ @@ -294,12 +346,41 @@ async function publishApiRevisions( return revA - revB; }); - // Publish each revision in order + // The current revision is already published as the root API, so skip a + // revision artifact that carries the same number — otherwise we re-create / + // collide with it (e.g. the root PUT created at ;rev=N for a fresh + // multi-revision API, or a stale ;rev=N folder from an older extract). + const rootJson = await store.readResource(config.sourceDir, apiDescriptor); + const rootRevision = (rootJson?.properties as Record | undefined)?.apiRevision; + const rootRevisionNumber = + typeof rootRevision === 'string' && rootRevision !== '' ? Number(rootRevision) : undefined; + + // Publish each revision in order; a failed revision must fail the API — + // otherwise errors are silently swallowed and the exit code stays 0. + let publishedCount = 0; for (const revDescriptor of sortedRevisions) { - await publishResource(client, store, context, revDescriptor, config); + if ( + rootRevisionNumber !== undefined && + extractRevisionNumber(getNamePart(revDescriptor.nameParts, 0)) === rootRevisionNumber + ) { + logger.debug( + `Skipping revision ${getNamePart(revDescriptor.nameParts, 0)} — already published as the root API` + ); + continue; + } + const result = await publishResource(client, store, context, revDescriptor, config); + if (result.status === 'failed') { + throw new Error( + `Failed to publish revision ${getNamePart(revDescriptor.nameParts, 0)}: ` + + `${result.error?.message ?? 'unknown error'}` + ); + } + if (result.status === 'success') publishedCount++; } - return sortedRevisions.length; + // Return only revisions actually published so the caller does not run a + // spurious active-revision alignment PUT when nothing changed. + return publishedCount; } /** @@ -467,8 +548,15 @@ async function reconcileOperationsAfterSpecImport( const patchBody: Record = { properties: patchProps }; + // Artifact lookup above uses the canonical descriptor; the PATCH must target + // the deployed (env-mapped) name so reconciliation hits the API that the + // root create actually produced under environment mapping. + const patchDescriptor = config.envMapping + ? mapDescriptor(descriptor, config.envMapping) + : descriptor; + try { - await client.patchResource(context, descriptor, patchBody); + await client.patchResource(context, patchDescriptor, patchBody); logger.debug(`Reconciled operation "${getNamePart(descriptor.nameParts, 1)}" after spec import`); } catch (error) { logger.warn( diff --git a/src/services/delete-unmatched-service.ts b/src/services/delete-unmatched-service.ts index 1e1b44fa..0c2c7848 100644 --- a/src/services/delete-unmatched-service.ts +++ b/src/services/delete-unmatched-service.ts @@ -29,10 +29,54 @@ import type { ApimServiceContext, ResourceDescriptor } from '../models/types.js' import type { PublishConfig } from '../models/config.js'; import { ResourceType } from '../models/resource-types.js'; import { getTopologicalOrder } from '../lib/dependency-graph.js'; -import { getNameFromNameParts } from '../lib/resource-path.js'; +import { + getNameFromNameParts, + getNamePart, + getApiRootName, + isApiRevisionName, +} from '../lib/resource-path.js'; import { logger } from '../lib/logger.js'; import { toCanonicalDescriptor } from './env-mapper.js'; +/** + * Drop ;rev=N API deletes whose base API (same workspace) is also queued for + * deletion. The base API delete uses deleteRevisions=true and removes all + * revisions in one call, so individual revision deletes are redundant and can + * hit APIM's "Cannot delete the current revision of an API" error. + * + * Shared by the real delete path and the dry-run reporter so the preview + * matches what publish would actually delete. + */ +export function filterRevisionDeletesHandledByBaseApi( + descriptors: ResourceDescriptor[] +): ResourceDescriptor[] { + const baseApiKeys = new Set(); + for (const descriptor of descriptors) { + if (descriptor.type !== ResourceType.Api) { + continue; + } + const apiName = getNamePart(descriptor.nameParts, 0); + if (!isApiRevisionName(apiName)) { + baseApiKeys.add(`${descriptor.workspace ?? ''}::${apiName}`); + } + } + + if (baseApiKeys.size === 0) { + return descriptors; + } + + return descriptors.filter((descriptor) => { + if (descriptor.type !== ResourceType.Api) { + return true; + } + const apiName = getNamePart(descriptor.nameParts, 0); + if (!isApiRevisionName(apiName)) { + return true; + } + return !baseApiKeys.has(`${descriptor.workspace ?? ''}::${getApiRootName(apiName)}`); + }); +} + /** * Built-in groups that should never be deleted */ diff --git a/src/services/dry-run-reporter.ts b/src/services/dry-run-reporter.ts index ff9ad071..9ccc66d7 100644 --- a/src/services/dry-run-reporter.ts +++ b/src/services/dry-run-reporter.ts @@ -15,7 +15,10 @@ import { buildResourceLabel } from '../lib/resource-uri.js'; import { getNamePart } from '../lib/resource-path.js'; import { ResourceType } from '../models/resource-types.js'; import { logger } from '../lib/logger.js'; -import { computeDeleteActions } from './delete-unmatched-service.js'; +import { + computeDeleteActions, + filterRevisionDeletesHandledByBaseApi, +} from './delete-unmatched-service.js'; export interface DryRunAction { operation: 'PUT' | 'DELETE' | 'SKIP'; @@ -106,8 +109,10 @@ export async function generateDryRunReport( // In incremental mode, use precomputed deleted descriptors from git diff. // Otherwise, if delete-unmatched is enabled, calculate full unmatched deletes. + // Apply the same ;rev=N filtering as the real delete path so the preview + // matches what publish would actually delete. if (incrementalDeletedDescriptors.length > 0) { - for (const descriptor of incrementalDeletedDescriptors) { + for (const descriptor of filterRevisionDeletesHandledByBaseApi(incrementalDeletedDescriptors)) { try { const existing = await client.getResource(context, descriptor); @@ -145,12 +150,14 @@ export async function generateDryRunReport( } } } else if (config.deleteUnmatched) { - const deleteActions = await computeDeleteActionsForDryRun( - client, - store, - context, - config, - targetDescriptors + const deleteActions = filterRevisionDeletesHandledByBaseApi( + await computeDeleteActionsForDryRun( + client, + store, + context, + config, + targetDescriptors + ) ); for (const descriptor of deleteActions) { diff --git a/src/services/env-mapper.ts b/src/services/env-mapper.ts index 1f6bbe25..47cc78a8 100644 --- a/src/services/env-mapper.ts +++ b/src/services/env-mapper.ts @@ -167,9 +167,23 @@ export function toCanonicalDescriptor(d: ResourceDescriptor, m: EnvMapping): Res return { ...d, nameParts: [canonicalFirst, ...d.nameParts.slice(1)] }; } +/** + * Split an API name into base and ";rev=N" suffix so env affixes apply to the + * base name only (deployed form is "{prefix}{base}{suffix};rev=N"). The ;rev=N + * suffix is only meaningful for API revisions, so non-API types are never split. + */ +function splitRevisionSuffix(name: string, type: ResourceType): { base: string; revSuffix: string } { + if (type !== ResourceType.Api) return { base: name, revSuffix: '' }; + const idx = name.indexOf(';rev='); + return idx === -1 + ? { base: name, revSuffix: '' } + : { base: name.slice(0, idx), revSuffix: name.slice(idx) }; +} + export function toDeployedName(name: string, type: ResourceType, m: EnvMapping): string { if (!m.appliesTo.has(type)) return name; - return `${m.prefix}${name}${m.suffix}`; + const { base, revSuffix } = splitRevisionSuffix(name, type); + return `${m.prefix}${base}${m.suffix}${revSuffix}`; } /** @@ -181,10 +195,11 @@ export function toCanonicalName(deployedName: string, type: ResourceType, m: Env if (!m.appliesTo.has(type)) return deployedName; if (!isInEnvNamespace(deployedName, type, m)) return undefined; - let name = deployedName; + const { base, revSuffix } = splitRevisionSuffix(deployedName, type); + let name = base; if (m.prefix) name = name.slice(m.prefix.length); if (m.suffix) name = name.slice(0, name.length - m.suffix.length); - return name; + return name + revSuffix; } /** @@ -193,8 +208,9 @@ export function toCanonicalName(deployedName: string, type: ResourceType, m: Env */ export function isInEnvNamespace(deployedName: string, type: ResourceType, m: EnvMapping): boolean { if (!m.appliesTo.has(type)) return true; - if (deployedName.length < m.prefix.length + m.suffix.length) return false; - return deployedName.startsWith(m.prefix) && deployedName.endsWith(m.suffix); + const { base } = splitRevisionSuffix(deployedName, type); + if (base.length < m.prefix.length + m.suffix.length) return false; + return base.startsWith(m.prefix) && base.endsWith(m.suffix); } /** diff --git a/src/services/publish-service.ts b/src/services/publish-service.ts index 0184ec67..d1052b92 100644 --- a/src/services/publish-service.ts +++ b/src/services/publish-service.ts @@ -17,14 +17,17 @@ import { logger } from '../lib/logger.js'; import { isAutoGeneratedId } from '../lib/auto-generated.js'; import { EXIT_SUCCESS, EXIT_PARTIAL, EXIT_FATAL } from '../lib/exit-codes.js'; import { buildResourceLabel } from '../lib/resource-uri.js'; -import { getNamePart, isChildType, isTopLevelSingleton } from '../lib/resource-path.js'; +import { getNamePart, isChildType, isTopLevelSingleton, isApiRevisionName, getApiRootName } from '../lib/resource-path.js'; // Import from other agents' files (will be created in parallel) import { publishResource, ResourcePublishResult, buildKnownArtifactSets } from './resource-publisher.js'; import { publishApi } from './api-publisher.js'; import { publishProduct } from './product-publisher.js'; import { generateDryRunReport, DryRunReport } from './dry-run-reporter.js'; -import { computeDeleteActions } from './delete-unmatched-service.js'; +import { + computeDeleteActions, + filterRevisionDeletesHandledByBaseApi, +} from './delete-unmatched-service.js'; import { computeGitDiff } from './git-diff-service.js'; import { scanForRedactionMarkers } from './secret-redaction-guard.js'; import { hasNamedValueOverride } from './override-merger.js'; @@ -344,19 +347,23 @@ async function executePuts( } } else if (tier === 2) { const tier2Descriptors = filterApiRevisionsHandledByRootApis(descriptors); + const apiDescriptors = tier2Descriptors.filter((d) => d.type === ResourceType.Api); + const nonApiDescriptors = tier2Descriptors.filter((d) => d.type !== ResourceType.Api); - const { mcpApis, regularTier2 } = await splitMcpApis( + const { mcpApis, regularTier2: regularApis } = await splitMcpApis( store, config.sourceDir, - tier2Descriptors + apiDescriptors ); - await publishAndOutput(client, store, context, config, regularTier2, results); + await publishAndOutput(client, store, context, config, regularApis, results); if (mcpApis.length > 0) { - logger.debug(`Publishing ${mcpApis.length} MCP API resource(s) after regular tier 2 resources`); + logger.debug(`Publishing ${mcpApis.length} MCP API resource(s) after regular APIs`); await publishAndOutput(client, store, context, config, mcpApis, results); } + + await publishAndOutput(client, store, context, config, nonApiDescriptors, results); } else { // For tiers 3/4, exclude child resources whose parent is being published // in tier 2 (publishApi/publishProduct handle their children internally). @@ -625,14 +632,6 @@ function filterApiRevisionsHandledByRootApis( }); } -function isApiRevisionName(apiName: string): boolean { - return apiName.includes(';rev='); -} - -function getApiRootName(apiName: string): string { - return apiName.split(';rev=')[0] ?? apiName; -} - /** * Execute DELETE operations in reverse dependency order (tier 4 → tier 1). */ @@ -674,9 +673,15 @@ async function executeDeletesForDescriptors( const results: PublishActionResult[] = []; + // When a base API is being deleted, the DELETE uses deleteRevisions=true and + // removes all of its revisions in one call. Drop individual ;rev=N deletes + // whose base API is also in the delete set to avoid redundant/racy deletes and + // the "Cannot delete the current revision of an API" error. + const effectiveDescriptors = filterRevisionDeletesHandledByBaseApi(deleteDescriptors); + // Group by tier const tierGroups = new Map(); - for (const descriptor of deleteDescriptors) { + for (const descriptor of effectiveDescriptors) { const tier = getResourceTier(descriptor.type); if (!tierGroups.has(tier)) { tierGroups.set(tier, []); diff --git a/src/services/resource-publisher.ts b/src/services/resource-publisher.ts index 9fdfb963..23dc51a1 100644 --- a/src/services/resource-publisher.ts +++ b/src/services/resource-publisher.ts @@ -59,16 +59,48 @@ export function buildKnownArtifactSets(descriptors: ResourceDescriptor[]): Known * Check if a specific property has an explicit override in the given section. * Uses case-insensitive resource name matching (matching override-merger behavior). */ -function hasExplicitPropertyOverride( +export function hasExplicitPropertyOverride( resourceName: string, propertyKey: string, section: OverrideSection | undefined, ): boolean { - if (!section) return false; + return getExplicitPropertyOverride(resourceName, propertyKey, section) !== undefined; +} + +/** Return the explicit override value for a property, if any (case-insensitive name match). */ +function getExplicitPropertyOverride( + resourceName: string, + propertyKey: string, + section: OverrideSection | undefined, +): unknown { + if (!section) return undefined; const lowerName = resourceName.toLowerCase(); const matchingKey = Object.keys(section).find((k) => k.toLowerCase() === lowerName); - if (!matchingKey) return false; - return Object.hasOwn(section[matchingKey].properties, propertyKey); + if (!matchingKey) return undefined; + const properties = section[matchingKey]?.properties as Record | undefined; + if (!properties || !Object.hasOwn(properties, propertyKey)) return undefined; + return properties[propertyKey]; +} + +/** + * True when the environment override explicitly supplies the LEGACY auth + * representation (oAuth2/openid) without also supplying the collections. + * Collection or metadata-only overrides keep the default collection precedence. + */ +export function prefersLegacyAuthOverride( + resourceName: string, + section: OverrideSection | undefined, +): boolean { + const overrideAuth = getExplicitPropertyOverride(resourceName, 'authenticationSettings', section); + if (!overrideAuth || typeof overrideAuth !== 'object') return false; + const auth = overrideAuth as Record; + + const suppliesLegacy = auth.oAuth2 != null || auth.openid != null; + const suppliesCollections = + (Array.isArray(auth.oAuth2AuthenticationSettings) && auth.oAuth2AuthenticationSettings.length > 0) || + (Array.isArray(auth.openidAuthenticationSettings) && auth.openidAuthenticationSettings.length > 0); + + return suppliesLegacy && !suppliesCollections; } /** @@ -110,6 +142,59 @@ const WIKI_TYPES = new Set([ ResourceType.ProductWiki, ]); +/** + * Normalize API authenticationSettings for PUT. + * + * APIM's GET returns both the legacy singular fields (oAuth2, openid) and the + * newer collections (oAuth2AuthenticationSettings, openidAuthenticationSettings), + * but PUT rejects payloads containing both: "Cannot use OAuth2AuthenticationSettings + * in combination with OAuth2 nor openid". Keep the collections (a superset of the + * singular fields) when they are non-empty; otherwise keep the singular fields and + * drop the empty collection keys. + * + * When `preferLegacyFields` is set (the environment override explicitly provides + * authenticationSettings), non-null legacy fields win over extracted collections — + * otherwise the override would be silently discarded. + */ +export function normalizeApiAuthenticationSettings( + json: Record, + options?: { preferLegacyFields?: boolean } +): Record { + const props = json.properties as Record | undefined; + const auth = props?.authenticationSettings as Record | undefined; + if (!auth) { + return json; + } + + const { + oAuth2, + openid, + oAuth2AuthenticationSettings, + openidAuthenticationSettings, + ...rest + } = auth; + + const hasLegacyValues = oAuth2 != null || openid != null; + const hasCollections = + (Array.isArray(oAuth2AuthenticationSettings) && oAuth2AuthenticationSettings.length > 0) || + (Array.isArray(openidAuthenticationSettings) && openidAuthenticationSettings.length > 0); + + const normalizedAuth: Record = { ...rest }; + if (hasCollections && !(options?.preferLegacyFields && hasLegacyValues)) { + if (Array.isArray(oAuth2AuthenticationSettings) && oAuth2AuthenticationSettings.length > 0) { + normalizedAuth.oAuth2AuthenticationSettings = oAuth2AuthenticationSettings; + } + if (Array.isArray(openidAuthenticationSettings) && openidAuthenticationSettings.length > 0) { + normalizedAuth.openidAuthenticationSettings = openidAuthenticationSettings; + } + } else { + if (oAuth2 !== undefined) normalizedAuth.oAuth2 = oAuth2; + if (openid !== undefined) normalizedAuth.openid = openid; + } + + return { ...json, properties: { ...props, authenticationSettings: normalizedAuth } }; +} + /** * Publish a single resource: read from store, apply overrides, PUT to APIM. * Preserves opaque JSON (FR-009). Uses applyOverrides for env-specific values. @@ -308,6 +393,9 @@ export async function publishResource( // validation errors in APIM's revision creation. if (descriptor.type === ResourceType.Api) { const apiName = getNamePart(descriptor.nameParts, 0); + json = normalizeApiAuthenticationSettings(json, { + preferLegacyFields: prefersLegacyAuthOverride(apiName, config.overrides?.apis), + }); if (apiName.includes(';rev=')) { const baseApiName = apiName.split(';rev=')[0]; const props = json.properties as Record | undefined; @@ -325,6 +413,23 @@ export async function publishResource( if (!Object.hasOwn(cleanProps, 'isCurrent')) { cleanProps.isCurrent = false; } + // Revision creation copies apiRevisionDescription from the current + // revision via sourceApiId; send an explicit empty string when absent so + // the copy cannot inherit it. (properties.description must NOT be sent — + // APIM rejects changing Description for non-current revisions.) + if (!Object.hasOwn(cleanProps, 'apiRevisionDescription')) { + cleanProps.apiRevisionDescription = ''; + } + if ( + Object.hasOwn(cleanProps, 'description') && + hasExplicitPropertyOverride(apiName, 'description', config.overrides?.apis) + ) { + logger.warn( + `Ignoring 'description' override for revision '${apiName}': ` + + `APIM only accepts description changes on the current revision.` + ); + } + delete cleanProps.description; json = { ...json, properties: cleanProps }; } } diff --git a/tests/unit/clients/apim-client.test.ts b/tests/unit/clients/apim-client.test.ts index 9864fac5..6758d881 100644 --- a/tests/unit/clients/apim-client.test.ts +++ b/tests/unit/clients/apim-client.test.ts @@ -784,6 +784,79 @@ describe('ApimClient.deleteResource provisioning polling', () => { }); }); +describe('ApimClient.deleteResource revision and reference handling', () => { + let client: ApimClient; + let fetchSpy: ReturnType; + + beforeEach(() => { + client = new ApimClient(); + 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.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + // Bug 1: deleting a revisioned API must use deleteRevisions=true, otherwise + // APIM rejects the base (current-revision) delete. + it('appends deleteRevisions=true when deleting a base API', async () => { + fetchSpy.mockResolvedValueOnce(makeResponse(200, { name: 'orders-api' })); + + const deleted = await client.deleteResource(testContext, { + type: ResourceType.Api, + nameParts: ['orders-api'], + }); + + expect(deleted).toBe(true); + const [url, options] = fetchSpy.mock.calls[0]; + expect(options.method).toBe('DELETE'); + expect(url).toContain('/apis/orders-api?'); + expect(url).toContain('deleteRevisions=true'); + }); + + it('does not append deleteRevisions=true when deleting a single revision', async () => { + fetchSpy.mockResolvedValueOnce(makeResponse(200, { name: 'orders-api;rev=2' })); + + const deleted = await client.deleteResource(testContext, { + type: ResourceType.Api, + nameParts: ['orders-api;rev=2'], + }); + + expect(deleted).toBe(true); + const [url] = fetchSpy.mock.calls[0]; + expect(url).not.toContain('deleteRevisions=true'); + }); + + // Bug 2: a policy fragment still referenced by the service policy cannot be + // deleted; the prune should skip it (return false) rather than throw. + it('skips a policy fragment that is still referenced by another entity', async () => { + const body = { + error: { + code: 'ValidationError', + message: + "The Policy Fragment 'global-security-headers' is used by the following entities:\r\n/policies/policy\r\n", + details: null, + }, + }; + fetchSpy.mockResolvedValueOnce(makeResponse(400, body)); + + const deleted = await client.deleteResource(testContext, { + type: ResourceType.PolicyFragment, + nameParts: ['global-security-headers'], + }); + + expect(deleted).toBe(false); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); +}); + describe('ApimClient HTTP 429 rate limiting', () => { let client: ApimClient; let fetchSpy: ReturnType; @@ -887,6 +960,72 @@ describe('ApimClient HTTP 429 rate limiting', () => { }); }); +describe('ApimClient HTTP 409 conflict handling', () => { + let client: ApimClient; + let fetchSpy: ReturnType; + + beforeEach(() => { + client = new ApimClient(); + 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.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('should retry PessimisticConcurrencyConflict responses on PUT operations', async () => { + fetchSpy + .mockResolvedValueOnce( + makeResponse(409, { + error: { + code: 'PessimisticConcurrencyConflict', + message: 'Operation on the API is in progress', + }, + }) + ) + .mockResolvedValueOnce(makeResponse(200, { name: 'my-api' })); + + const descriptor = { + type: ResourceType.Api, + nameParts: ['my-api'], + }; + + await expect(client.putResource(testContext, descriptor, {})) + .resolves.toEqual({ name: 'my-api' }); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it('should not retry unrelated HTTP 409 responses', async () => { + fetchSpy.mockResolvedValueOnce( + makeResponse(409, { + error: { + code: 'AnotherConflict', + message: 'A non-transient conflict', + }, + }) + ); + + const descriptor = { + type: ResourceType.Api, + nameParts: ['my-api'], + }; + + await expect(client.putResource(testContext, descriptor, {})) + .rejects.toMatchObject({ + status: 409, + code: 'AnotherConflict', + }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); +}); + describe('ApimClient.getApiSpecification', () => { let client: ApimClient; let fetchSpy: ReturnType; diff --git a/tests/unit/services/api-product-extractor.test.ts b/tests/unit/services/api-product-extractor.test.ts index 6e5d7b17..05263836 100644 --- a/tests/unit/services/api-product-extractor.test.ts +++ b/tests/unit/services/api-product-extractor.test.ts @@ -405,11 +405,12 @@ describe('api-extractor', () => { expect(result.revisions).toHaveLength(2); }); - it('should skip revision 1 (main API)', async () => { + it('should skip the current revision (not a hard-coded revision 1)', async () => { const client = createMockClient({ listApiRevisions: async function* () { - yield { apiRevision: '1', apiId: '/apis/my-api' }; - yield { apiRevision: '2', apiId: '/apis/my-api;rev=2' }; + // Current revision is 2 (not 1); revision 1 is non-current. + yield { apiRevision: '1', apiId: '/apis/my-api;rev=1', isCurrent: false }; + yield { apiRevision: '2', apiId: '/apis/my-api', isCurrent: true }; }, getResource: vi.fn().mockImplementation(async (_ctx: unknown, desc: ResourceDescriptor) => { if ((desc.nameParts[0] ?? '').includes(';rev=')) { @@ -428,9 +429,10 @@ describe('api-extractor', () => { '/output' ); - // Only revision 2 should be extracted (revision 1 = main API) + // The current revision (2) is represented by the main API folder and is + // skipped here; the non-current revision (1) is extracted as ;rev=1. expect(result.revisions).toHaveLength(1); - expect(result.revisions[0]?.descriptor.nameParts[0]).toBe('my-api;rev=2'); + expect(result.revisions[0]?.descriptor.nameParts[0]).toBe('my-api;rev=1'); }); it('should extract API operations', async () => { diff --git a/tests/unit/services/api-publisher.test.ts b/tests/unit/services/api-publisher.test.ts index c847bd11..e0cb8db7 100644 --- a/tests/unit/services/api-publisher.test.ts +++ b/tests/unit/services/api-publisher.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 { applyOverrides } from '../../../src/services/override-merger.js'; +import { EnvMapping } from '../../../src/services/env-mapper.js'; // Mock resource-publisher so we can verify call sequence const mockPublishResource = vi.fn(); @@ -112,6 +113,189 @@ describe('api-publisher', () => { ); }); + it('creates a fresh root API at its source revision number to avoid rev collisions', async () => { + const client = createMockClient(); + client.getResource.mockResolvedValue(undefined); // API does not exist on target + const store = createMockStore([]); + store.readResource.mockImplementation(async (_dir: string, d: ResourceDescriptor) => { + if (d.type === ResourceType.Api && d.nameParts[0] === 'orders-api') { + return { name: 'orders-api', properties: { apiRevision: '3', isCurrent: true } }; + } + return null; + }); + + const apiDescriptor: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: ['orders-api'], + }; + + await publishApi(client, store, testContext, apiDescriptor, testConfig); + + expect(client.putResource).toHaveBeenCalledWith( + testContext, + expect.objectContaining({ nameParts: ['orders-api;rev=3'] }), + expect.objectContaining({ name: 'orders-api' }) + ); + }); + + it('checks existence and appends ;rev=N using the env-mapped target name', async () => { + const client = createMockClient(); + client.getResource.mockResolvedValue(undefined); + const store = createMockStore([]); + store.readResource.mockImplementation(async (_dir: string, d: ResourceDescriptor) => { + if (d.type === ResourceType.Api && d.nameParts[0] === 'orders-api') { + return { name: 'orders-api', properties: { apiRevision: '3', isCurrent: true } }; + } + return null; + }); + + const apiDescriptor: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: ['orders-api'], + }; + const config: PublishConfig = { + ...testConfig, + envMapping: { + prefix: 'dev-', + suffix: '-eu', + appliesTo: new Set([ResourceType.Api]), + }, + }; + + await publishApi(client, store, testContext, apiDescriptor, config); + + // Existence check uses the mapped name, not the canonical one + expect(client.getResource).toHaveBeenCalledWith( + testContext, + expect.objectContaining({ nameParts: ['dev-orders-api-eu'] }) + ); + // ;rev=N is appended after affixing so the suffix cannot corrupt it + expect(client.putResource).toHaveBeenCalledWith( + testContext, + expect.objectContaining({ nameParts: ['dev-orders-api-eu;rev=3'] }), + expect.anything() + ); + }); + + it('uses the deployed name for every root PUT when the API already exists', async () => { + const client = createMockClient(); + client.getResource.mockResolvedValue({ name: 'dev-orders-api-eu' }); // exists on target + const store = createMockStore([]); + store.readResource.mockImplementation(async (_dir: string, d: ResourceDescriptor) => { + if (d.type === ResourceType.Api && d.nameParts[0] === 'orders-api') { + return { name: 'orders-api', properties: { apiRevision: '3', isCurrent: true } }; + } + return null; + }); + + const apiDescriptor: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: ['orders-api'], + }; + const config: PublishConfig = { + ...testConfig, + envMapping: { + prefix: 'dev-', + suffix: '-eu', + appliesTo: new Set([ResourceType.Api]), + }, + }; + + await publishApi(client, store, testContext, apiDescriptor, config); + + // Every direct PUT must target the deployed name — never the canonical one + const putNames = client.putResource.mock.calls.map( + (c: unknown[]) => (c[1] as ResourceDescriptor).nameParts[0] + ); + expect(putNames.length).toBeGreaterThan(0); + for (const name of putNames) { + expect(name).toBe('dev-orders-api-eu'); + } + }); + + it('fails the API publish when a revision publish fails', async () => { + const client = createMockClient(); + client.getResource.mockResolvedValue(undefined); + const revisionDescriptor: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: ['orders-api;rev=2'], + }; + const store = createMockStore([revisionDescriptor]); + store.readResource.mockImplementation(async (_dir: string, d: ResourceDescriptor) => { + if (d.type === ResourceType.Api && d.nameParts[0] === 'orders-api') { + return { name: 'orders-api', properties: { apiRevision: '3', isCurrent: true } }; + } + return null; + }); + mockPublishResource.mockResolvedValue({ + descriptor: revisionDescriptor, + status: 'failed', + action: 'noop', + error: new Error('HTTP 400: ValidationError'), + }); + + const apiDescriptor: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: ['orders-api'], + }; + + const result = await publishApi(client, store, testContext, apiDescriptor, testConfig); + + expect(result.status).toBe('failed'); + expect(result.error?.message).toContain('orders-api;rev=2'); + expect(result.error?.message).toContain('HTTP 400: ValidationError'); + }); + + it('keeps the plain root PUT when the API already exists on the target', async () => { + const client = createMockClient(); + client.getResource.mockResolvedValue({ name: 'orders-api' }); // exists + const store = createMockStore([]); + store.readResource.mockImplementation(async (_dir: string, d: ResourceDescriptor) => { + if (d.type === ResourceType.Api && d.nameParts[0] === 'orders-api') { + return { name: 'orders-api', properties: { apiRevision: '3', isCurrent: true } }; + } + return null; + }); + + const apiDescriptor: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: ['orders-api'], + }; + + await publishApi(client, store, testContext, apiDescriptor, testConfig); + + expect(client.putResource).toHaveBeenCalledWith( + testContext, + expect.objectContaining({ nameParts: ['orders-api'] }), + expect.anything() + ); + }); + + it('keeps the plain root PUT when the source revision is 1', async () => { + const client = createMockClient(); + client.getResource.mockResolvedValue(undefined); + const store = createMockStore([]); + store.readResource.mockImplementation(async (_dir: string, d: ResourceDescriptor) => { + if (d.type === ResourceType.Api && d.nameParts[0] === 'orders-api') { + return { name: 'orders-api', properties: { apiRevision: '1', isCurrent: true } }; + } + return null; + }); + + const apiDescriptor: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: ['orders-api'], + }; + + await publishApi(client, store, testContext, apiDescriptor, testConfig); + + expect(client.putResource).toHaveBeenCalledWith( + testContext, + expect.objectContaining({ nameParts: ['orders-api'] }), + expect.anything() + ); + }); + it('should publish MCP metadata from apiInformation.json and rewrite tool operationIds to the target service', async () => { const client = createMockClient(); const store = createMockStore([]); @@ -143,6 +327,34 @@ describe('api-publisher', () => { }]); }); + it('env-maps the API name in MCP tool operationIds on the root PUT', async () => { + const client = createMockClient(); + const store = createMockStore([]); + store.readResource.mockResolvedValue({ + name: 'orders-api', + properties: { + type: 'mcp', + mcpProperties: { serverUrl: 'https://example.com/mcp' }, + mcpTools: [{ + name: 'invokeTool', + operationId: '/subscriptions/src-sub/resourceGroups/src-rg/providers/Microsoft.ApiManagement/service/src-apim/apis/orders-api/operations/get-orders', + }], + }, + }); + + const envMapping: EnvMapping = { prefix: 'dev-', suffix: '-eu', appliesTo: new Set([ResourceType.Api]) }; + const config: PublishConfig = { ...testConfig, envMapping }; + + await publishApi(client, store, testContext, { type: ResourceType.Api, nameParts: ['orders-api'] }, config); + + const [, , payload] = client.putResource.mock.calls[0] as [unknown, unknown, Record]; + const mcpTools = (payload.properties as Record).mcpTools as Array>; + // operationId must reference the affixed API name that the PUT targets. + expect(mcpTools[0].operationId).toBe( + '/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.ApiManagement/service/apim-1/apis/dev-orders-api-eu/operations/get-orders' + ); + }); + it('should filter out legacy McpServer child artifacts while still publishing other API children', async () => { const client = createMockClient(); const children = [ @@ -296,6 +508,53 @@ describe('api-publisher', () => { expect(client.putResource).toHaveBeenCalledTimes(1); }); + it('skips the revision artifact whose number equals the current root revision', async () => { + const client = createMockClient(); + const store = createMockStore([ + { type: ResourceType.Api, nameParts: ['orders-api;rev=2'] }, + { type: ResourceType.Api, nameParts: ['orders-api;rev=3'] }, + ]); + // Root is the current revision 3; the ;rev=3 artifact duplicates it. + store.readResource.mockImplementation(async (_dir: string, d: ResourceDescriptor) => { + if (d.type === ResourceType.Api && !(d.nameParts[0] ?? '').includes(';rev=')) { + return { name: 'orders-api', properties: { apiRevision: '3', isCurrent: true } }; + } + return null; + }); + + const apiDescriptor: ResourceDescriptor = { type: ResourceType.Api, nameParts: ['orders-api'] }; + + await publishApi(client, store, testContext, apiDescriptor, testConfig); + + const publishedRevs = mockPublishResource.mock.calls.map( + (c: unknown[]) => (c[3] as ResourceDescriptor).nameParts[0] + ); + expect(publishedRevs).toContain('orders-api;rev=2'); + expect(publishedRevs).not.toContain('orders-api;rev=3'); + }); + + it('does not run the alignment PUT when the only revision is the skipped duplicate', async () => { + const client = createMockClient(); + const store = createMockStore([ + { type: ResourceType.Api, nameParts: ['orders-api;rev=3'] }, + ]); + // Root is the current rev 3; the only revision artifact duplicates it → skipped. + store.readResource.mockImplementation(async (_dir: string, d: ResourceDescriptor) => { + if (d.type === ResourceType.Api && !(d.nameParts[0] ?? '').includes(';rev=')) { + return { name: 'orders-api', properties: { apiRevision: '3', isCurrent: true } }; + } + return null; + }); + + const apiDescriptor: ResourceDescriptor = { type: ResourceType.Api, nameParts: ['orders-api'] }; + + await publishApi(client, store, testContext, apiDescriptor, testConfig); + + // Nothing was actually published → no spurious second (alignment) root PUT. + expect(mockPublishResource).not.toHaveBeenCalled(); + expect(client.putResource).toHaveBeenCalledTimes(1); + }); + it('should replay root API without re-importing specification after revisions', async () => { const client = createMockClient(); const revisions = [{ type: ResourceType.Api, nameParts: ['orders-api;rev=2'] }]; @@ -413,6 +672,72 @@ describe('api-publisher', () => { expect(mockPublishResource.mock.calls[0][3].nameParts[0]).toBe('orders-api;rev=2'); }); + it('fails the API publish when a revision publish fails (no silent success)', async () => { + const client = createMockClient(); + const store = createMockStore([ + { type: ResourceType.Api, nameParts: ['orders-api;rev=2'] }, + ]); + // Root API publishes fine; the revision publish returns failed. + mockPublishResource.mockResolvedValue({ + descriptor: { type: ResourceType.Api, nameParts: ['orders-api;rev=2'] }, + status: 'failed', + action: 'noop', + error: new Error('revision boom'), + }); + + const apiDescriptor: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: ['orders-api'], + }; + + const result = await publishApi(client, store, testContext, apiDescriptor, testConfig); + + expect(result.status).toBe('failed'); + expect(result.error?.message).toContain('orders-api;rev=2'); + }); + + it('reconciles operations against the env-mapped API name after spec import', async () => { + const client = createMockClient(); + const store = createMockStore([ + { type: ResourceType.ApiOperation, nameParts: ['orders-api', 'get-orders'] }, + ]); + store.readResource.mockImplementation(async (_dir: string, d: ResourceDescriptor) => { + if (d.type === ResourceType.Api && !(d.nameParts[0] ?? '').includes(';rev=')) { + return { name: 'orders-api', properties: {} }; + } + if (d.type === ResourceType.ApiOperation) { + return { name: 'get-orders', properties: { displayName: 'Get Orders', method: 'GET', urlTemplate: '/orders' } }; + } + return null; + }); + store.readContent.mockImplementation(async (_dir: string, _d: ResourceDescriptor, kind: string) => { + return kind === 'specification' + ? { content: 'openapi: 3.0.0\ninfo:\n title: t\n version: "1"\npaths: {}\n', format: 'yaml' } + : undefined; + }); + // Execute the reconcile tasks so the PATCH target can be asserted. + mockRunParallel.mockImplementation(async (tasks: Array<() => Promise>) => { + for (const t of tasks) await t(); + }); + + const envMapping: EnvMapping = { prefix: 'dev-', suffix: '-eu', appliesTo: new Set([ResourceType.Api]) }; + const config: PublishConfig = { ...testConfig, envMapping }; + + await publishApi( + client, store, testContext, + { type: ResourceType.Api, nameParts: ['orders-api'] }, + config + ); + + const opPatch = client.patchResource.mock.calls.find( + (c: unknown[]) => (c[1] as ResourceDescriptor).type === ResourceType.ApiOperation + ); + expect(opPatch).toBeDefined(); + // PATCH must target the affixed API name, not the canonical one. + expect((opPatch![1] as ResourceDescriptor).nameParts[0]).toBe('dev-orders-api-eu'); + expect((opPatch![1] as ResourceDescriptor).nameParts[1]).toBe('get-orders'); + }); + it('should publish API child resources in parallel', async () => { const client = createMockClient(); const children = [ diff --git a/tests/unit/services/delete-unmatched-service.test.ts b/tests/unit/services/delete-unmatched-service.test.ts index b09e2964..881534e2 100644 --- a/tests/unit/services/delete-unmatched-service.test.ts +++ b/tests/unit/services/delete-unmatched-service.test.ts @@ -5,7 +5,10 @@ */ import { describe, it, expect, vi } from 'vitest'; -import { computeDeleteActions } from '../../../src/services/delete-unmatched-service.js'; +import { + computeDeleteActions, + filterRevisionDeletesHandledByBaseApi, +} from '../../../src/services/delete-unmatched-service.js'; import { ResourceType } from '../../../src/models/resource-types.js'; import { ApimServiceContext, ResourceDescriptor } from '../../../src/models/types.js'; import { PublishConfig } from '../../../src/models/config.js'; @@ -227,4 +230,38 @@ describe('delete-unmatched-service', () => { }); }); }); + + describe('filterRevisionDeletesHandledByBaseApi', () => { + it('drops ;rev=N deletes when the base API is also queued', () => { + const descriptors: ResourceDescriptor[] = [ + { type: ResourceType.Api, nameParts: ['orders-api'] }, + { type: ResourceType.Api, nameParts: ['orders-api;rev=2'] }, + { type: ResourceType.Api, nameParts: ['other-api;rev=2'] }, + { type: ResourceType.Tag, nameParts: ['a-tag'] }, + ]; + + const result = filterRevisionDeletesHandledByBaseApi(descriptors); + + expect(result.map((d) => d.nameParts[0])).toEqual([ + 'orders-api', + 'other-api;rev=2', + 'a-tag', + ]); + }); + + it('keeps revision deletes when the base API is in a different workspace', () => { + const descriptors: ResourceDescriptor[] = [ + { type: ResourceType.Api, nameParts: ['orders-api'], workspace: 'ws1' }, + { type: ResourceType.Api, nameParts: ['orders-api;rev=2'], workspace: 'ws2' }, + { type: ResourceType.Api, nameParts: ['orders-api;rev=3'], workspace: 'ws1' }, + ]; + + const result = filterRevisionDeletesHandledByBaseApi(descriptors); + + expect(result.map((d) => `${d.workspace}:${d.nameParts[0]}`)).toEqual([ + 'ws1:orders-api', + 'ws2:orders-api;rev=2', + ]); + }); + }); }); diff --git a/tests/unit/services/dry-run-reporter.test.ts b/tests/unit/services/dry-run-reporter.test.ts index c702f930..9ceaad9a 100644 --- a/tests/unit/services/dry-run-reporter.test.ts +++ b/tests/unit/services/dry-run-reporter.test.ts @@ -182,6 +182,29 @@ describe('dry-run-reporter', () => { expect(report.actions[0].operation).toBe('PUT'); }); + it('filters ;rev=N incremental deletes covered by a base API delete', async () => { + const client = createMockClient(); + client.getResource.mockResolvedValue({ name: 'x' }); // everything "exists" + const store = createMockStore(); + + const incrementalDeleted: ResourceDescriptor[] = [ + { type: ResourceType.Api, nameParts: ['orders-api'] }, + { type: ResourceType.Api, nameParts: ['orders-api;rev=2'] }, + ]; + + const report = await generateDryRunReport( + store, + client, + testContext, + testConfig, + [], + incrementalDeleted + ); + + const deletes = report.actions.filter((a) => a.operation === 'DELETE'); + expect(deletes.map((a) => a.name)).toEqual(['orders-api']); + }); + it('should include summary with correct counts', async () => { const client = createMockClient(new Map([ ['NamedValue:nv1', false], diff --git a/tests/unit/services/env-mapper.test.ts b/tests/unit/services/env-mapper.test.ts index 536c52d6..12533cd2 100644 --- a/tests/unit/services/env-mapper.test.ts +++ b/tests/unit/services/env-mapper.test.ts @@ -274,6 +274,36 @@ describe('env-mapper', () => { const mb = bothMapping('dev-', '-qa'); expect(toCanonicalName('d', ResourceType.Api, mb)).toBeUndefined(); }); + + it('affixes the base API name only, keeping the ;rev=N suffix intact', () => { + const mb = bothMapping('dev-', '-eu'); + expect(toDeployedName('orders-api;rev=2', ResourceType.Api, mb)).toBe('dev-orders-api-eu;rev=2'); + expect(toCanonicalName('dev-orders-api-eu;rev=2', ResourceType.Api, mb)).toBe('orders-api;rev=2'); + }); + + it('revision names round-trip through mapDescriptor with suffix mappings', () => { + const ms = suffixMapping('-dev'); + const revDescriptor: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: ['orders-api;rev=10'], + }; + const mapped = mapDescriptor(revDescriptor, ms); + expect(mapped.nameParts).toEqual(['orders-api-dev;rev=10']); + expect(toCanonicalName(mapped.nameParts[0], ResourceType.Api, ms)).toBe('orders-api;rev=10'); + }); + + it('isInEnvNamespace recognizes deployed revision names', () => { + const mb = bothMapping('dev-', '-eu'); + expect(isInEnvNamespace('dev-orders-api-eu;rev=2', ResourceType.Api, mb)).toBe(true); + expect(isInEnvNamespace('orders-api;rev=2', ResourceType.Api, mb)).toBe(false); + }); + + it('does NOT split ;rev= for non-API types (affix applies to the whole name)', () => { + const mb = bothMapping('dev-', '-eu'); + // ;rev= is only meaningful for API revisions; other types treat it literally. + expect(toDeployedName('token;rev=2', ResourceType.NamedValue, mb)).toBe('dev-token;rev=2-eu'); + expect(toCanonicalName('dev-token;rev=2-eu', ResourceType.NamedValue, mb)).toBe('token;rev=2'); + }); }); // ─── isInEnvNamespace ──────────────────────────────────────────────────── diff --git a/tests/unit/services/publish-service.test.ts b/tests/unit/services/publish-service.test.ts index 013322f8..2b2ad606 100644 --- a/tests/unit/services/publish-service.test.ts +++ b/tests/unit/services/publish-service.test.ts @@ -13,7 +13,15 @@ import { LogLevel } from '../../../src/lib/logger.js'; // Mock service dependencies vi.mock('../../../src/services/git-diff-service.js'); vi.mock('../../../src/services/dry-run-reporter.js'); -vi.mock('../../../src/services/delete-unmatched-service.js'); +vi.mock('../../../src/services/delete-unmatched-service.js', async () => { + const actual = await vi.importActual( + '../../../src/services/delete-unmatched-service.js' + ); + return { + ...actual, + computeDeleteActions: vi.fn(), + }; +}); vi.mock('../../../src/services/api-publisher.js'); vi.mock('../../../src/services/product-publisher.js'); @@ -248,6 +256,48 @@ describe('publish-service', () => { expect(apiCallOrder).toEqual(['src-rest-openapi', 'src-mcp-from-api']); }); + it('should wait for APIs to finish publishing before publishing Products in tier 2', async () => { + const resources: ResourceDescriptor[] = [ + { type: ResourceType.Product, nameParts: ['petstore-product'] }, + { type: ResourceType.Api, nameParts: ['swagger-petstore'] }, + ]; + const client = createMockClient(); + const store = createMockStore(resources); + let finishApi!: () => void; + const apiFinished = new Promise((resolve) => { + finishApi = resolve; + }); + + vi.mocked(publishApi).mockImplementation(async (_client, _store, _context, descriptor) => { + await apiFinished; + return { + descriptor, + status: 'success', + action: 'put', + }; + }); + + const config: PublishConfig = { + service: testContext, + sourceDir: '/source', + dryRun: false, + deleteUnmatched: false, + logLevel: LogLevel.INFO, + }; + + const publishPromise = runPublish(client, store, config); + await vi.waitFor(() => expect(publishApi).toHaveBeenCalledOnce()); + + try { + expect(publishProduct).not.toHaveBeenCalled(); + } finally { + finishApi(); + await publishPromise; + } + + expect(publishProduct).toHaveBeenCalledOnce(); + }); + it('should not publish revision APIs as standalone resources when root API is in the same batch', async () => { const resources: ResourceDescriptor[] = [ { type: ResourceType.Api, nameParts: ['orders-api;rev=2'] }, @@ -573,6 +623,44 @@ describe('publish-service', () => { expect(result.totalDeletes).toBe(1); }); + it('deletes a revisioned API via the base API only, not individual revisions', async () => { + const resources = [ + { type: ResourceType.Tag, nameParts: ['tag1'] }, + ]; + + const client = createMockClient(); + const store = createMockStore(resources); + + // computeDeleteActions returns both the base API and one of its revisions. + vi.mocked(computeDeleteActions).mockResolvedValue([ + { type: ResourceType.Api, nameParts: ['orders-api'] }, + { type: ResourceType.Api, nameParts: ['orders-api;rev=2'] }, + ]); + + const config: PublishConfig = { + service: testContext, + sourceDir: '/source', + dryRun: false, + deleteUnmatched: true, + logLevel: LogLevel.INFO, + }; + + const result = await runPublish(client, store, config); + + // Base API delete (which uses deleteRevisions=true) is issued once. + expect(client.deleteResource).toHaveBeenCalledWith( + testContext, + { type: ResourceType.Api, nameParts: ['orders-api'] } + ); + // The individual revision delete is dropped to avoid the + // "Cannot delete the current revision of an API" error. + expect(client.deleteResource).not.toHaveBeenCalledWith( + testContext, + { type: ResourceType.Api, nameParts: ['orders-api;rev=2'] } + ); + expect(result.totalDeletes).toBe(1); + }); + it('should output per-resource status lines', async () => { const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); diff --git a/tests/unit/services/resource-publisher.test.ts b/tests/unit/services/resource-publisher.test.ts index 79f05637..0431a11b 100644 --- a/tests/unit/services/resource-publisher.test.ts +++ b/tests/unit/services/resource-publisher.test.ts @@ -5,7 +5,11 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { publishResource } from '../../../src/services/resource-publisher.js'; +import { + publishResource, + normalizeApiAuthenticationSettings, + prefersLegacyAuthOverride, +} from '../../../src/services/resource-publisher.js'; import { ResourceType } from '../../../src/models/resource-types.js'; import { ApimServiceContext, ResourceDescriptor } from '../../../src/models/types.js'; import { PublishConfig } from '../../../src/models/config.js'; @@ -873,6 +877,50 @@ describe('resource-publisher', () => { expect(props).toHaveProperty('path', '/api'); }); + it('sends an explicit empty apiRevisionDescription when absent and never sends description', async () => { + const client = createMockClient(); + const store = createMockStore(); + store.readResource.mockResolvedValue({ + name: 'my-api;rev=2', + // description present in the artifact; apiRevisionDescription absent + properties: { path: '/api', description: 'copied from current' }, + }); + + const descriptor: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: ['my-api;rev=2'], + }; + + await publishResource(client, store, testContext, descriptor, testConfig); + + const props = (client.putResource.mock.calls[0][2] as Record) + .properties as Record; + // Prevents inheriting the current revision's description via sourceApiId copy + expect(props.apiRevisionDescription).toBe(''); + // APIM rejects Description changes for non-current revisions + expect(props).not.toHaveProperty('description'); + }); + + it('preserves an explicit apiRevisionDescription from the artifact', async () => { + const client = createMockClient(); + const store = createMockStore(); + store.readResource.mockResolvedValue({ + name: 'my-api;rev=2', + properties: { path: '/api', apiRevisionDescription: 'v1' }, + }); + + const descriptor: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: ['my-api;rev=2'], + }; + + await publishResource(client, store, testContext, descriptor, testConfig); + + const props = (client.putResource.mock.calls[0][2] as Record) + .properties as Record; + expect(props.apiRevisionDescription).toBe('v1'); + }); + it('does not inject sourceApiId for non-revision APIs', async () => { const client = createMockClient(); const store = createMockStore(); @@ -1242,4 +1290,180 @@ describe('resource-publisher', () => { expect(creds.instrumentationKey).toBe('raw-instrumentation-key-value'); }); }); + + describe('normalizeApiAuthenticationSettings', () => { + it('keeps collections and drops singular fields when collections are non-empty', () => { + const json = { + properties: { + displayName: 'api', + authenticationSettings: { + oAuth2: { authorizationServerId: 'aad-oauth-2-0', scope: null }, + openid: null, + oAuth2AuthenticationSettings: [ + { authorizationServerId: 'aad-oauth-2-0', scope: null }, + ], + openidAuthenticationSettings: [], + returnProtectedResourceMetadata: false, + }, + }, + }; + + const result = normalizeApiAuthenticationSettings(json); + const auth = (result.properties as Record) + .authenticationSettings as Record; + + expect(auth.oAuth2).toBeUndefined(); + expect(auth.openid).toBeUndefined(); + expect(auth.oAuth2AuthenticationSettings).toHaveLength(1); + expect(auth.openidAuthenticationSettings).toBeUndefined(); + expect(auth.returnProtectedResourceMetadata).toBe(false); + }); + + it('keeps singular fields and drops empty collection keys when collections are empty', () => { + const json = { + properties: { + authenticationSettings: { + oAuth2: { authorizationServerId: 'server-1' }, + oAuth2AuthenticationSettings: [], + openidAuthenticationSettings: [], + }, + }, + }; + + const result = normalizeApiAuthenticationSettings(json); + const auth = (result.properties as Record) + .authenticationSettings as Record; + + expect(auth.oAuth2).toEqual({ authorizationServerId: 'server-1' }); + expect(auth.oAuth2AuthenticationSettings).toBeUndefined(); + expect(auth.openidAuthenticationSettings).toBeUndefined(); + }); + + it('returns json unchanged when authenticationSettings is absent', () => { + const json = { properties: { displayName: 'api' } }; + expect(normalizeApiAuthenticationSettings(json)).toBe(json); + }); + + it('prefers non-null legacy fields over collections when preferLegacyFields is set', () => { + const json = { + properties: { + authenticationSettings: { + oAuth2: { authorizationServerId: 'override-server' }, + oAuth2AuthenticationSettings: [ + { authorizationServerId: 'extracted-server' }, + ], + }, + }, + }; + + const result = normalizeApiAuthenticationSettings(json, { preferLegacyFields: true }); + const auth = (result.properties as Record) + .authenticationSettings as Record; + + expect(auth.oAuth2).toEqual({ authorizationServerId: 'override-server' }); + expect(auth.oAuth2AuthenticationSettings).toBeUndefined(); + expect(auth.openidAuthenticationSettings).toBeUndefined(); + }); + + it('falls back to collections when preferLegacyFields is set but legacy fields are null', () => { + const json = { + properties: { + authenticationSettings: { + oAuth2: null, + openid: null, + oAuth2AuthenticationSettings: [ + { authorizationServerId: 'extracted-server' }, + ], + }, + }, + }; + + const result = normalizeApiAuthenticationSettings(json, { preferLegacyFields: true }); + const auth = (result.properties as Record) + .authenticationSettings as Record; + + expect(auth.oAuth2AuthenticationSettings).toHaveLength(1); + expect(auth.oAuth2).toBeUndefined(); + expect(auth.openid).toBeUndefined(); + }); + }); + + describe('prefersLegacyAuthOverride', () => { + it('is true when the override supplies only legacy fields', () => { + const section = { + 'my-api': { + properties: { + authenticationSettings: { oAuth2: { authorizationServerId: 'override-server' } }, + }, + }, + }; + expect(prefersLegacyAuthOverride('my-api', section)).toBe(true); + }); + + it('is false when the override supplies collections', () => { + const section = { + 'my-api': { + properties: { + authenticationSettings: { + oAuth2AuthenticationSettings: [{ authorizationServerId: 'override-server' }], + }, + }, + }, + }; + expect(prefersLegacyAuthOverride('my-api', section)).toBe(false); + }); + + it('is false when the override supplies both representations', () => { + const section = { + 'my-api': { + properties: { + authenticationSettings: { + oAuth2: { authorizationServerId: 'legacy' }, + oAuth2AuthenticationSettings: [{ authorizationServerId: 'a' }, { authorizationServerId: 'b' }], + }, + }, + }, + }; + expect(prefersLegacyAuthOverride('my-api', section)).toBe(false); + }); + + it('is false for metadata-only authentication overrides', () => { + const section = { + 'my-api': { + properties: { + authenticationSettings: { returnProtectedResourceMetadata: true }, + }, + }, + }; + expect(prefersLegacyAuthOverride('my-api', section)).toBe(false); + }); + + it('is false when there is no authenticationSettings override', () => { + expect(prefersLegacyAuthOverride('my-api', undefined)).toBe(false); + expect(prefersLegacyAuthOverride('my-api', { 'my-api': { properties: { path: '/x' } } })).toBe(false); + }); + + it('matches by the exact resource key (revision names are not base-inherited)', () => { + // A revision-specific legacy override is honored only under its full key — + // this is why the caller looks up the full API name, not the stripped base. + const revisionSection = { + 'my-api;rev=2': { + properties: { + authenticationSettings: { oAuth2: { authorizationServerId: 'rev-server' } }, + }, + }, + }; + expect(prefersLegacyAuthOverride('my-api;rev=2', revisionSection)).toBe(true); + + // A base override must NOT leak into a revision publish (keys differ). + const baseSection = { + 'my-api': { + properties: { + authenticationSettings: { oAuth2: { authorizationServerId: 'base-server' } }, + }, + }, + }; + expect(prefersLegacyAuthOverride('my-api;rev=2', baseSection)).toBe(false); + }); + }); });