From 82ad9056340d381027bb2ac3ee1a976918105f04 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 31 Aug 2026 23:05:43 +0000 Subject: [PATCH 01/14] Initial plan From 27ac24c7754b6b207682aaa4b3206eddf0b87b72 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 31 Aug 2026 23:07:47 +0000 Subject: [PATCH 02/14] Initial plan From b0af0efb7d36349ec5d6dbd1fab00f23b00d4473 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 31 Aug 2026 23:08:04 +0000 Subject: [PATCH 03/14] fix: retry pessimistic concurrency conflicts (Fixes #252) Co-authored-by: azaslonov <2320302+azaslonov@users.noreply.github.com> --- src/clients/apim-client.ts | 15 ++++-- tests/unit/clients/apim-client.test.ts | 66 ++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/src/clients/apim-client.ts b/src/clients/apim-client.ts index 5d9eaf36..99cfbf3d 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) { diff --git a/tests/unit/clients/apim-client.test.ts b/tests/unit/clients/apim-client.test.ts index 9864fac5..5590b3fe 100644 --- a/tests/unit/clients/apim-client.test.ts +++ b/tests/unit/clients/apim-client.test.ts @@ -887,6 +887,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; From 3cc53d898e5b8341979416a1cb5354dcbdc1a033 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 31 Aug 2026 23:11:05 +0000 Subject: [PATCH 04/14] fix: publish APIs before products (Closes #101) Co-authored-by: azaslonov <2320302+azaslonov@users.noreply.github.com> --- src/services/publish-service.ts | 12 ++++-- tests/unit/services/publish-service.test.ts | 42 +++++++++++++++++++++ 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/src/services/publish-service.ts b/src/services/publish-service.ts index 0184ec67..8aa07470 100644 --- a/src/services/publish-service.ts +++ b/src/services/publish-service.ts @@ -344,19 +344,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). diff --git a/tests/unit/services/publish-service.test.ts b/tests/unit/services/publish-service.test.ts index 013322f8..930fc22b 100644 --- a/tests/unit/services/publish-service.test.ts +++ b/tests/unit/services/publish-service.test.ts @@ -248,6 +248,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'] }, From bd433aecfaca5c12f29577def76b8c0bada8ff33 Mon Sep 17 00:00:00 2001 From: Aleksey Zheltov Date: Thu, 3 Sep 2026 17:03:21 +0000 Subject: [PATCH 05/14] chore: bump fast-uri to 3.1.7 (npm audit fix) --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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": [ { From 4eb4e77a7c7b99c41c7d89d50bb9551df6264cc8 Mon Sep 17 00:00:00 2001 From: Aleksey Zheltov Date: Thu, 3 Sep 2026 17:04:58 +0000 Subject: [PATCH 06/14] fix: revision delete/create conflicts, in-use fragment skip, auth settings PUT, and current-revision extract duplication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1 — Cannot delete the current revision: - apim-client.ts: deleteResource appends &deleteRevisions=true for base API deletes (id without ;rev=N), removing the API and all revisions in one call instead of erroring on the current revision. - publish-service.ts: filterRevisionDeletesHandledByBaseApi strips individual ;rev=N deletes when the base API is also being deleted. Bug 2 — policy fragment referenced by service policy: - apim-client.ts: deleteResource treats 'is used by the following entities' as non-fatal — resource is logged WARN and marked 'skipped' instead of failing the run. Bug 3 (Closes #258) — extract mishandles APIs whose current revision is not rev=1: - api-extractor.ts: extractApiRevisions skips the current revision via isCurrent === true instead of hard-coding revision number '1'. - Renamed/updated test to explicitly cover current=2, non-current=1, asserting the non-current revision (;rev=1) is what gets extracted. Bug 4 — root API create collides with source ;rev=N artifact: - api-publisher.ts: resolveRootApiPutDescriptor detects when the source's current revision number is > 1 and the API does not yet exist on the target; in that case the root is PUT at apis/{name};rev=N instead of apis/{name} (which APIM always creates as revision 1), preventing it from colliding with and silently absorbing the real ;rev=1 artifact. Existing APIs keep the plain root PUT since their current revision cannot be renumbered. - api-publisher.ts: publishApiRevisions now throws when a revision publish fails, instead of silently swallowing the error and returning exit code 0. Bug 5 — authenticationSettings PUT rejected when legacy and collection fields are both present: - resource-publisher.ts: normalizeApiAuthenticationSettings drops the legacy oAuth2/openid fields when the newer oAuth2AuthenticationSettings/openidAuthenticationSettings collections are non-empty (APIM GET returns both, but PUT rejects the combination), keeping the singular fields only when the collections are empty. Verified via unit tests (new cases added for all scenarios) and manual dry-run/live extract+publish against dev-apim-uk-01 with a multi-revision API (webapitest;rev=1, ;rev=2). --- src/clients/apim-client.ts | 22 +++++- src/services/api-extractor.ts | 6 +- src/services/api-publisher.ts | 62 +++++++++++++-- src/services/publish-service.ts | 44 ++++++++++- src/services/resource-publisher.ts | 48 ++++++++++++ tests/unit/clients/apim-client.test.ts | 73 ++++++++++++++++++ .../services/api-product-extractor.test.ts | 12 +-- tests/unit/services/api-publisher.test.ts | 75 +++++++++++++++++++ tests/unit/services/publish-service.test.ts | 38 ++++++++++ .../unit/services/resource-publisher.test.ts | 59 ++++++++++++++- 10 files changed, 424 insertions(+), 15 deletions(-) diff --git a/src/clients/apim-client.ts b/src/clients/apim-client.ts index 99cfbf3d..f1d85bde 100644 --- a/src/clients/apim-client.ts +++ b/src/clients/apim-client.ts @@ -463,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 { @@ -491,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/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..b80622dd 100644 --- a/src/services/api-publisher.ts +++ b/src/services/api-publisher.ts @@ -14,6 +14,7 @@ import type { PublishConfig } from '../models/config.js'; import * as yaml from 'js-yaml'; import { ResourceType } from '../models/resource-types.js'; import { + normalizeApiAuthenticationSettings, normalizeMcpToolOperationIds, publishResource, type ResourcePublishResult, @@ -54,8 +55,13 @@ 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); + // 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, config); + const rootResult = await publishRootApi(client, store, context, descriptor, config, { + putDescriptor, + }); if (rootResult.status !== 'success') { return rootResult; } @@ -148,6 +154,44 @@ 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. + */ +async function resolveRootApiPutDescriptor( + client: IApimClient, + store: IArtifactStore, + context: ApimServiceContext, + descriptor: 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 descriptor; + } + + const existing = await client.getResource(context, descriptor); + if (existing) { + return descriptor; + } + + return { + ...descriptor, + nameParts: [ + `${getNamePart(descriptor.nameParts, 0)};rev=${rev}`, + ...descriptor.nameParts.slice(1), + ], + }; } /** @@ -181,6 +225,7 @@ async function publishRootApi( // Apply overrides json = applyOverrides(descriptor, json, config.overrides); + json = normalizeApiAuthenticationSettings(json); const isCurrent = getApiIsCurrent(json); // Try to read the specification file for this API @@ -239,7 +284,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, @@ -294,9 +339,16 @@ async function publishApiRevisions( return revA - revB; }); - // Publish each revision in order + // Publish each revision in order; a failed revision must fail the API — + // otherwise errors are silently swallowed and the exit code stays 0. for (const revDescriptor of sortedRevisions) { - await publishResource(client, store, context, revDescriptor, config); + 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'}` + ); + } } return sortedRevisions.length; diff --git a/src/services/publish-service.ts b/src/services/publish-service.ts index 8aa07470..ef48a3ab 100644 --- a/src/services/publish-service.ts +++ b/src/services/publish-service.ts @@ -637,6 +637,42 @@ function getApiRootName(apiName: string): string { return apiName.split(';rev=')[0] ?? apiName; } +/** + * Drop ;rev=N API deletes whose base API is also queued for deletion. The base + * API delete uses deleteRevisions=true and removes all revisions in one call, so + * deleting individual revisions separately is redundant and can hit APIM's + * "Cannot delete the current revision of an API" error. + */ +function filterRevisionDeletesHandledByBaseApi( + descriptors: ResourceDescriptor[] +): ResourceDescriptor[] { + const baseApiNames = new Set(); + for (const descriptor of descriptors) { + if (descriptor.type !== ResourceType.Api) { + continue; + } + const apiName = getNamePart(descriptor.nameParts, 0); + if (!isApiRevisionName(apiName)) { + baseApiNames.add(apiName); + } + } + + if (baseApiNames.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 !baseApiNames.has(getApiRootName(apiName)); + }); +} + /** * Execute DELETE operations in reverse dependency order (tier 4 → tier 1). */ @@ -678,9 +714,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..904d9121 100644 --- a/src/services/resource-publisher.ts +++ b/src/services/resource-publisher.ts @@ -110,6 +110,53 @@ 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. + */ +export function normalizeApiAuthenticationSettings( + json: Record +): 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 hasCollections = + (Array.isArray(oAuth2AuthenticationSettings) && oAuth2AuthenticationSettings.length > 0) || + (Array.isArray(openidAuthenticationSettings) && openidAuthenticationSettings.length > 0); + + const normalizedAuth: Record = { ...rest }; + if (hasCollections) { + 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. @@ -307,6 +354,7 @@ export async function publishResource( // base API to copy structure from. Also strip null properties that cause // validation errors in APIM's revision creation. if (descriptor.type === ResourceType.Api) { + json = normalizeApiAuthenticationSettings(json); const apiName = getNamePart(descriptor.nameParts, 0); if (apiName.includes(';rev=')) { const baseApiName = apiName.split(';rev=')[0]; diff --git a/tests/unit/clients/apim-client.test.ts b/tests/unit/clients/apim-client.test.ts index 5590b3fe..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; 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..817eedb0 100644 --- a/tests/unit/services/api-publisher.test.ts +++ b/tests/unit/services/api-publisher.test.ts @@ -112,6 +112,81 @@ 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('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([]); diff --git a/tests/unit/services/publish-service.test.ts b/tests/unit/services/publish-service.test.ts index 930fc22b..c1c39539 100644 --- a/tests/unit/services/publish-service.test.ts +++ b/tests/unit/services/publish-service.test.ts @@ -615,6 +615,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..dba67790 100644 --- a/tests/unit/services/resource-publisher.test.ts +++ b/tests/unit/services/resource-publisher.test.ts @@ -5,7 +5,10 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { publishResource } from '../../../src/services/resource-publisher.js'; +import { + publishResource, + normalizeApiAuthenticationSettings, +} 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'; @@ -1242,4 +1245,58 @@ 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); + }); + }); }); From 76216407342f9ecf69aaa4b2fc04e18264396414 Mon Sep 17 00:00:00 2001 From: Aleksey Zheltov Date: Thu, 3 Sep 2026 17:46:18 +0000 Subject: [PATCH 07/14] fix: apply revision-delete filtering to dry runs and preserve workspace scope Addresses Copilot review: filterRevisionDeletesHandledByBaseApi is now shared via delete-unmatched-service, applied to both dry-run delete paths (incremental and delete-unmatched), and keys base APIs by (workspace, name). --- src/lib/resource-path.ts | 10 ++++ src/services/delete-unmatched-service.ts | 46 ++++++++++++++++- src/services/dry-run-reporter.ts | 23 ++++++--- src/services/publish-service.ts | 51 ++----------------- .../services/delete-unmatched-service.test.ts | 39 +++++++++++++- tests/unit/services/dry-run-reporter.test.ts | 23 +++++++++ tests/unit/services/publish-service.test.ts | 10 +++- 7 files changed, 145 insertions(+), 57 deletions(-) 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/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/publish-service.ts b/src/services/publish-service.ts index ef48a3ab..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'; @@ -629,50 +632,6 @@ function filterApiRevisionsHandledByRootApis( }); } -function isApiRevisionName(apiName: string): boolean { - return apiName.includes(';rev='); -} - -function getApiRootName(apiName: string): string { - return apiName.split(';rev=')[0] ?? apiName; -} - -/** - * Drop ;rev=N API deletes whose base API is also queued for deletion. The base - * API delete uses deleteRevisions=true and removes all revisions in one call, so - * deleting individual revisions separately is redundant and can hit APIM's - * "Cannot delete the current revision of an API" error. - */ -function filterRevisionDeletesHandledByBaseApi( - descriptors: ResourceDescriptor[] -): ResourceDescriptor[] { - const baseApiNames = new Set(); - for (const descriptor of descriptors) { - if (descriptor.type !== ResourceType.Api) { - continue; - } - const apiName = getNamePart(descriptor.nameParts, 0); - if (!isApiRevisionName(apiName)) { - baseApiNames.add(apiName); - } - } - - if (baseApiNames.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 !baseApiNames.has(getApiRootName(apiName)); - }); -} - /** * Execute DELETE operations in reverse dependency order (tier 4 → tier 1). */ 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/publish-service.test.ts b/tests/unit/services/publish-service.test.ts index c1c39539..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'); From 08fc89fb935a7f0e82b2dcf0734ab0de1f05e26d Mon Sep 17 00:00:00 2001 From: Aleksey Zheltov Date: Thu, 3 Sep 2026 18:28:44 +0000 Subject: [PATCH 08/14] fix: prevent revision description inheritance from current revision Revision creation via sourceApiId copies apiRevisionDescription from the current revision; send an explicit empty string when the artifact omits it. properties.description is never sent for ;rev=N PUTs (APIM rejects Description changes on non-current revisions); an explicit override of it now logs a warning instead of being silently dropped. --- src/services/resource-publisher.ts | 41 +++++++-- .../unit/services/resource-publisher.test.ts | 87 +++++++++++++++++++ 2 files changed, 123 insertions(+), 5 deletions(-) diff --git a/src/services/resource-publisher.ts b/src/services/resource-publisher.ts index 904d9121..b2558233 100644 --- a/src/services/resource-publisher.ts +++ b/src/services/resource-publisher.ts @@ -59,7 +59,7 @@ 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, @@ -68,7 +68,9 @@ function hasExplicitPropertyOverride( 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); + const properties = section[matchingKey]?.properties as Record | undefined; + if (!properties) return false; + return Object.hasOwn(properties, propertyKey); } /** @@ -119,9 +121,14 @@ const WIKI_TYPES = new Set([ * 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 + json: Record, + options?: { preferLegacyFields?: boolean } ): Record { const props = json.properties as Record | undefined; const auth = props?.authenticationSettings as Record | undefined; @@ -137,12 +144,13 @@ export function normalizeApiAuthenticationSettings( ...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) { + if (hasCollections && !(options?.preferLegacyFields && hasLegacyValues)) { if (Array.isArray(oAuth2AuthenticationSettings) && oAuth2AuthenticationSettings.length > 0) { normalizedAuth.oAuth2AuthenticationSettings = oAuth2AuthenticationSettings; } @@ -354,8 +362,14 @@ export async function publishResource( // base API to copy structure from. Also strip null properties that cause // validation errors in APIM's revision creation. if (descriptor.type === ResourceType.Api) { - json = normalizeApiAuthenticationSettings(json); const apiName = getNamePart(descriptor.nameParts, 0); + json = normalizeApiAuthenticationSettings(json, { + preferLegacyFields: hasExplicitPropertyOverride( + apiName.split(';rev=')[0] ?? apiName, + 'authenticationSettings', + config.overrides?.apis + ), + }); if (apiName.includes(';rev=')) { const baseApiName = apiName.split(';rev=')[0]; const props = json.properties as Record | undefined; @@ -373,6 +387,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(baseApiName ?? 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/services/resource-publisher.test.ts b/tests/unit/services/resource-publisher.test.ts index dba67790..060c42d3 100644 --- a/tests/unit/services/resource-publisher.test.ts +++ b/tests/unit/services/resource-publisher.test.ts @@ -876,6 +876,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(); @@ -1298,5 +1342,48 @@ describe('resource-publisher', () => { 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(); + }); }); }); From 90a31a30cd148eca3151d8bac82660f2b00c995b Mon Sep 17 00:00:00 2001 From: Aleksey Zheltov Date: Thu, 3 Sep 2026 18:38:05 +0000 Subject: [PATCH 09/14] fix: address review - env-mapped revision targets and override-aware root auth normalization - resolveRootApiPutDescriptor checks API existence via the deployed (env-mapped) name and appends ;rev=N after affixing so suffix mappings cannot corrupt the revision suffix - root API normalization passes preferLegacyFields (same explicit-override signal as publishResource) so root and revision APIs honor authenticationSettings overrides consistently - add regression tests: revision publish failure propagates to a failed publishApi result; env-mapped existence check and rev suffix placement --- src/services/api-publisher.ts | 24 +++++++- tests/unit/services/api-publisher.test.ts | 72 +++++++++++++++++++++++ 2 files changed, 93 insertions(+), 3 deletions(-) diff --git a/src/services/api-publisher.ts b/src/services/api-publisher.ts index b80622dd..23bcd420 100644 --- a/src/services/api-publisher.ts +++ b/src/services/api-publisher.ts @@ -14,6 +14,7 @@ import type { PublishConfig } from '../models/config.js'; import * as yaml from 'js-yaml'; import { ResourceType } from '../models/resource-types.js'; import { + hasExplicitPropertyOverride, normalizeApiAuthenticationSettings, normalizeMcpToolOperationIds, publishResource, @@ -21,6 +22,7 @@ import { } from './resource-publisher.js'; import { runParallel } from '../lib/parallel-runner.js'; import { applyOverrides } from './override-merger.js'; +import { toDeployedName } 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'; @@ -180,7 +182,17 @@ async function resolveRootApiPutDescriptor( return descriptor; } - const existing = await client.getResource(context, descriptor); + // Existence must be checked against the deployed (env-mapped) name, and the + // ;rev=N suffix appended after affixing so suffix mappings don't corrupt it. + const baseName = getNamePart(descriptor.nameParts, 0); + const deployedBaseName = config.envMapping + ? toDeployedName(baseName, ResourceType.Api, config.envMapping) + : baseName; + + const existing = await client.getResource(context, { + ...descriptor, + nameParts: [deployedBaseName, ...descriptor.nameParts.slice(1)], + }); if (existing) { return descriptor; } @@ -188,7 +200,7 @@ async function resolveRootApiPutDescriptor( return { ...descriptor, nameParts: [ - `${getNamePart(descriptor.nameParts, 0)};rev=${rev}`, + `${deployedBaseName};rev=${rev}`, ...descriptor.nameParts.slice(1), ], }; @@ -225,7 +237,13 @@ async function publishRootApi( // Apply overrides json = applyOverrides(descriptor, json, config.overrides); - json = normalizeApiAuthenticationSettings(json); + json = normalizeApiAuthenticationSettings(json, { + preferLegacyFields: hasExplicitPropertyOverride( + getNamePart(descriptor.nameParts, 0), + 'authenticationSettings', + config.overrides?.apis + ), + }); const isCurrent = getApiIsCurrent(json); // Try to read the specification file for this API diff --git a/tests/unit/services/api-publisher.test.ts b/tests/unit/services/api-publisher.test.ts index 817eedb0..268b0d4e 100644 --- a/tests/unit/services/api-publisher.test.ts +++ b/tests/unit/services/api-publisher.test.ts @@ -137,6 +137,78 @@ describe('api-publisher', () => { ); }); + 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('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 From 4a6c80e95eb92ffea130a79665b1641bdb38417d Mon Sep 17 00:00:00 2001 From: Aleksey Zheltov Date: Thu, 3 Sep 2026 18:52:45 +0000 Subject: [PATCH 10/14] fix: use deployed descriptor consistently for all direct APIM calls in publishApi Derive the env-mapped base descriptor once and use it for the existence check, root PUT, ;rev=N fresh-create target, revision alignment PUT, and imported-operation reconciliation. Previously only the fresh rev>1 branch was mapped, so publishes with envMapping could create or align an un-affixed API alongside the mapped one. --- src/services/api-publisher.ts | 73 ++++++++++------------- tests/unit/services/api-publisher.test.ts | 36 +++++++++++ 2 files changed, 67 insertions(+), 42 deletions(-) diff --git a/src/services/api-publisher.ts b/src/services/api-publisher.ts index 23bcd420..8b53c849 100644 --- a/src/services/api-publisher.ts +++ b/src/services/api-publisher.ts @@ -22,7 +22,7 @@ import { } from './resource-publisher.js'; import { runParallel } from '../lib/parallel-runner.js'; import { applyOverrides } from './override-merger.js'; -import { toDeployedName } from './env-mapper.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'; @@ -57,10 +57,23 @@ export async function publishApi( config: PublishConfig ): Promise { try { + // 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, config); + const putDescriptor = await resolveRootApiPutDescriptor( + client, + store, + context, + descriptor, + deployedDescriptor, + config + ); const rootResult = await publishRootApi(client, store, context, descriptor, config, { putDescriptor, }); @@ -72,7 +85,7 @@ export async function publishApi( await alignImportedOperationDescriptions( client, context, - descriptor, + deployedDescriptor, rootResult.operationIdsWithNullDescription ); } @@ -83,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; } @@ -168,40 +178,35 @@ interface PublishRootApiOptions { * 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 descriptor; + return deployedDescriptor; } - // Existence must be checked against the deployed (env-mapped) name, and the - // ;rev=N suffix appended after affixing so suffix mappings don't corrupt it. - const baseName = getNamePart(descriptor.nameParts, 0); - const deployedBaseName = config.envMapping - ? toDeployedName(baseName, ResourceType.Api, config.envMapping) - : baseName; - - const existing = await client.getResource(context, { - ...descriptor, - nameParts: [deployedBaseName, ...descriptor.nameParts.slice(1)], - }); + const existing = await client.getResource(context, deployedDescriptor); if (existing) { - return descriptor; + return deployedDescriptor; } return { - ...descriptor, + ...deployedDescriptor, nameParts: [ - `${deployedBaseName};rev=${rev}`, - ...descriptor.nameParts.slice(1), + `${getNamePart(deployedDescriptor.nameParts, 0)};rev=${rev}`, + ...deployedDescriptor.nameParts.slice(1), ], }; } @@ -314,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 */ diff --git a/tests/unit/services/api-publisher.test.ts b/tests/unit/services/api-publisher.test.ts index 268b0d4e..fc9ee622 100644 --- a/tests/unit/services/api-publisher.test.ts +++ b/tests/unit/services/api-publisher.test.ts @@ -176,6 +176,42 @@ describe('api-publisher', () => { ); }); + 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); From cb8049ea922440b6aaec11ad1dd71d106d4cfa98 Mon Sep 17 00:00:00 2001 From: Aleksey Zheltov Date: Thu, 3 Sep 2026 19:15:30 +0000 Subject: [PATCH 11/14] fix: revision-aware env mapping and representation-aware auth override precedence - env-mapper affixes the base API name and re-appends ;rev=N in both directions (toDeployedName/toCanonicalName/isInEnvNamespace), fixing revision PUT targets and delete-unmatched canonicalization under suffix mappings - prefersLegacyAuthOverride inspects which authenticationSettings representation the override explicitly supplies; collection and metadata-only overrides keep collection precedence --- src/services/api-publisher.ts | 5 +- src/services/env-mapper.ts | 24 ++++++-- src/services/resource-publisher.ts | 41 +++++++++++-- tests/unit/services/env-mapper.test.ts | 23 ++++++++ .../unit/services/resource-publisher.test.ts | 57 +++++++++++++++++++ 5 files changed, 136 insertions(+), 14 deletions(-) diff --git a/src/services/api-publisher.ts b/src/services/api-publisher.ts index 8b53c849..9cbdc45d 100644 --- a/src/services/api-publisher.ts +++ b/src/services/api-publisher.ts @@ -14,9 +14,9 @@ import type { PublishConfig } from '../models/config.js'; import * as yaml from 'js-yaml'; import { ResourceType } from '../models/resource-types.js'; import { - hasExplicitPropertyOverride, normalizeApiAuthenticationSettings, normalizeMcpToolOperationIds, + prefersLegacyAuthOverride, publishResource, type ResourcePublishResult, } from './resource-publisher.js'; @@ -243,9 +243,8 @@ async function publishRootApi( // Apply overrides json = applyOverrides(descriptor, json, config.overrides); json = normalizeApiAuthenticationSettings(json, { - preferLegacyFields: hasExplicitPropertyOverride( + preferLegacyFields: prefersLegacyAuthOverride( getNamePart(descriptor.nameParts, 0), - 'authenticationSettings', config.overrides?.apis ), }); diff --git a/src/services/env-mapper.ts b/src/services/env-mapper.ts index 1f6bbe25..5241c665 100644 --- a/src/services/env-mapper.ts +++ b/src/services/env-mapper.ts @@ -167,9 +167,21 @@ 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"). + */ +function splitRevisionSuffix(name: string): { base: string; revSuffix: string } { + 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); + return `${m.prefix}${base}${m.suffix}${revSuffix}`; } /** @@ -181,10 +193,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); + 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 +206,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); + 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/resource-publisher.ts b/src/services/resource-publisher.ts index b2558233..6a997e65 100644 --- a/src/services/resource-publisher.ts +++ b/src/services/resource-publisher.ts @@ -64,13 +64,43 @@ export function hasExplicitPropertyOverride( 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; + if (!matchingKey) return undefined; const properties = section[matchingKey]?.properties as Record | undefined; - if (!properties) return false; - return Object.hasOwn(properties, propertyKey); + 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; } /** @@ -364,9 +394,8 @@ export async function publishResource( if (descriptor.type === ResourceType.Api) { const apiName = getNamePart(descriptor.nameParts, 0); json = normalizeApiAuthenticationSettings(json, { - preferLegacyFields: hasExplicitPropertyOverride( + preferLegacyFields: prefersLegacyAuthOverride( apiName.split(';rev=')[0] ?? apiName, - 'authenticationSettings', config.overrides?.apis ), }); diff --git a/tests/unit/services/env-mapper.test.ts b/tests/unit/services/env-mapper.test.ts index 536c52d6..bc6dec2a 100644 --- a/tests/unit/services/env-mapper.test.ts +++ b/tests/unit/services/env-mapper.test.ts @@ -274,6 +274,29 @@ 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); + }); }); // ─── isInEnvNamespace ──────────────────────────────────────────────────── diff --git a/tests/unit/services/resource-publisher.test.ts b/tests/unit/services/resource-publisher.test.ts index 060c42d3..3c89ad84 100644 --- a/tests/unit/services/resource-publisher.test.ts +++ b/tests/unit/services/resource-publisher.test.ts @@ -8,6 +8,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; 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'; @@ -1386,4 +1387,60 @@ describe('resource-publisher', () => { 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); + }); + }); }); From 6b731ff1078770436f0cbea400edf5aaf24a2cc2 Mon Sep 17 00:00:00 2001 From: Aleksey Zheltov Date: Fri, 4 Sep 2026 11:04:32 +0400 Subject: [PATCH 12/14] fix: env-mapped op reconcile, revision-only env suffix, auth override key, revision-collision guard, tests --- src/services/api-publisher.ts | 27 +++++- src/services/env-mapper.ts | 12 ++- src/services/resource-publisher.ts | 5 +- tests/unit/services/api-publisher.test.ts | 92 +++++++++++++++++++ tests/unit/services/env-mapper.test.ts | 7 ++ .../unit/services/resource-publisher.test.ts | 23 +++++ 6 files changed, 156 insertions(+), 10 deletions(-) diff --git a/src/services/api-publisher.ts b/src/services/api-publisher.ts index 9cbdc45d..35d4fde3 100644 --- a/src/services/api-publisher.ts +++ b/src/services/api-publisher.ts @@ -345,9 +345,27 @@ async function publishApiRevisions( return revA - revB; }); + // 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. for (const revDescriptor of sortedRevisions) { + 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( @@ -525,8 +543,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/env-mapper.ts b/src/services/env-mapper.ts index 5241c665..47cc78a8 100644 --- a/src/services/env-mapper.ts +++ b/src/services/env-mapper.ts @@ -169,9 +169,11 @@ export function toCanonicalDescriptor(d: ResourceDescriptor, m: EnvMapping): Res /** * 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"). + * 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): { base: string; revSuffix: string } { +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: '' } @@ -180,7 +182,7 @@ function splitRevisionSuffix(name: string): { base: string; revSuffix: string } export function toDeployedName(name: string, type: ResourceType, m: EnvMapping): string { if (!m.appliesTo.has(type)) return name; - const { base, revSuffix } = splitRevisionSuffix(name); + const { base, revSuffix } = splitRevisionSuffix(name, type); return `${m.prefix}${base}${m.suffix}${revSuffix}`; } @@ -193,7 +195,7 @@ export function toCanonicalName(deployedName: string, type: ResourceType, m: Env if (!m.appliesTo.has(type)) return deployedName; if (!isInEnvNamespace(deployedName, type, m)) return undefined; - const { base, revSuffix } = splitRevisionSuffix(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); @@ -206,7 +208,7 @@ 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; - const { base } = splitRevisionSuffix(deployedName); + 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/resource-publisher.ts b/src/services/resource-publisher.ts index 6a997e65..aef35b1f 100644 --- a/src/services/resource-publisher.ts +++ b/src/services/resource-publisher.ts @@ -394,10 +394,7 @@ export async function publishResource( if (descriptor.type === ResourceType.Api) { const apiName = getNamePart(descriptor.nameParts, 0); json = normalizeApiAuthenticationSettings(json, { - preferLegacyFields: prefersLegacyAuthOverride( - apiName.split(';rev=')[0] ?? apiName, - config.overrides?.apis - ), + preferLegacyFields: prefersLegacyAuthOverride(apiName, config.overrides?.apis), }); if (apiName.includes(';rev=')) { const baseApiName = apiName.split(';rev=')[0]; diff --git a/tests/unit/services/api-publisher.test.ts b/tests/unit/services/api-publisher.test.ts index fc9ee622..a0c3e803 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(); @@ -479,6 +480,31 @@ 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('should replay root API without re-importing specification after revisions', async () => { const client = createMockClient(); const revisions = [{ type: ResourceType.Api, nameParts: ['orders-api;rev=2'] }]; @@ -596,6 +622,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/env-mapper.test.ts b/tests/unit/services/env-mapper.test.ts index bc6dec2a..12533cd2 100644 --- a/tests/unit/services/env-mapper.test.ts +++ b/tests/unit/services/env-mapper.test.ts @@ -297,6 +297,13 @@ describe('env-mapper', () => { 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/resource-publisher.test.ts b/tests/unit/services/resource-publisher.test.ts index 3c89ad84..0431a11b 100644 --- a/tests/unit/services/resource-publisher.test.ts +++ b/tests/unit/services/resource-publisher.test.ts @@ -1442,5 +1442,28 @@ describe('resource-publisher', () => { 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); + }); }); }); From 67f3c8b7506123ce749ddabd0df9d644e0c9b92e Mon Sep 17 00:00:00 2001 From: Aleksey Zheltov Date: Fri, 4 Sep 2026 12:41:22 +0400 Subject: [PATCH 13/14] fix: env-map MCP tool operationIds on root PUT and return only published revision count --- src/services/api-publisher.ts | 11 +++-- tests/unit/services/api-publisher.test.ts | 50 +++++++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/src/services/api-publisher.ts b/src/services/api-publisher.ts index 35d4fde3..5743887f 100644 --- a/src/services/api-publisher.ts +++ b/src/services/api-publisher.ts @@ -237,8 +237,9 @@ 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); @@ -356,6 +357,7 @@ async function publishApiRevisions( // 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) { if ( rootRevisionNumber !== undefined && @@ -373,9 +375,12 @@ async function publishApiRevisions( `${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; } /** diff --git a/tests/unit/services/api-publisher.test.ts b/tests/unit/services/api-publisher.test.ts index a0c3e803..e0cb8db7 100644 --- a/tests/unit/services/api-publisher.test.ts +++ b/tests/unit/services/api-publisher.test.ts @@ -327,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 = [ @@ -505,6 +533,28 @@ describe('api-publisher', () => { 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'] }]; From 956381db3e64489ed0a858c100440e842c6918b9 Mon Sep 17 00:00:00 2001 From: Aleksey Zheltov Date: Fri, 4 Sep 2026 13:38:45 +0400 Subject: [PATCH 14/14] fix: use full revision name for description-override warning (review) --- src/services/resource-publisher.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/resource-publisher.ts b/src/services/resource-publisher.ts index aef35b1f..23dc51a1 100644 --- a/src/services/resource-publisher.ts +++ b/src/services/resource-publisher.ts @@ -422,7 +422,7 @@ export async function publishResource( } if ( Object.hasOwn(cleanProps, 'description') && - hasExplicitPropertyOverride(baseApiName ?? apiName, 'description', config.overrides?.apis) + hasExplicitPropertyOverride(apiName, 'description', config.overrides?.apis) ) { logger.warn( `Ignoring 'description' override for revision '${apiName}': ` +