From 2a40bc28680dda99f142f99c3a4b131a02b49ff3 Mon Sep 17 00:00:00 2001 From: Alexey Zheltov Date: Mon, 5 Oct 2026 12:31:20 +0000 Subject: [PATCH 1/2] fix: strip source-service tagId from ApiTagDescription PUT payloads Extracted tagDescriptionInformation.json carries properties.tagId with the full ARM path of the SOURCE service's tag. Newer APIM api-versions validate ARM references in PUT bodies and reject cross-service publishes with "Cross-service resource references are not allowed". tagId is response-only per the ARM contract (the tag is identified by the request URL), so drop it before PUT. Same bug family as the top-level id / diagnostic loggerId fixes. Closes #312 --- src/services/resource-publisher.ts | 24 ++++++++++++++ .../unit/services/resource-publisher.test.ts | 32 +++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/src/services/resource-publisher.ts b/src/services/resource-publisher.ts index 9e3c1ef1..4cde62df 100644 --- a/src/services/resource-publisher.ts +++ b/src/services/resource-publisher.ts @@ -451,6 +451,13 @@ export async function publishResource( json = normalizeDiagnosticLoggerId(json, context, descriptor.workspace, config.envMapping); } + // Tag descriptions carry a read-only properties.tagId pointing at the + // SOURCE service; the tag is identified by the URL path, so drop it or + // APIM rejects the PUT with "Cross-service resource references are not allowed". + if (descriptor.type === ResourceType.ApiTagDescription) { + json = stripTagDescriptionTagId(json); + } + if (descriptor.type === ResourceType.Api) { json = normalizeApiVersionSetId( json, @@ -1323,6 +1330,23 @@ export function normalizeDiagnosticLoggerId( }; } +/** + * Drop the read-only `properties.tagId` from an ApiTagDescription payload. + * Extracted artifacts carry the SOURCE service's full tag ARM path; newer APIM + * api-versions validate it on PUT and reject cross-service references. The tag + * is already identified by the tagDescriptionId in the request URL. + */ +export function stripTagDescriptionTagId( + json: Record +): Record { + const props = json.properties as Record | undefined; + if (!props || !Object.hasOwn(props, 'tagId')) { + return json; + } + const { tagId: _omit, ...rest } = props; + return { ...json, properties: rest }; +} + export function normalizeApiVersionSetId( json: Record, context: ApimServiceContext, diff --git a/tests/unit/services/resource-publisher.test.ts b/tests/unit/services/resource-publisher.test.ts index 61c6078e..87d87235 100644 --- a/tests/unit/services/resource-publisher.test.ts +++ b/tests/unit/services/resource-publisher.test.ts @@ -10,6 +10,7 @@ import { resolveAssociationDeleteDescriptor, normalizeApiAuthenticationSettings, normalizeDiagnosticLoggerId, + stripTagDescriptionTagId, prefersLegacyAuthOverride, } from '../../../src/services/resource-publisher.js'; import { ResourceType } from '../../../src/models/resource-types.js'; @@ -2069,6 +2070,37 @@ describe('resource-publisher', () => { }); }); + describe('stripTagDescriptionTagId', () => { + it('drops the source-service tagId from the payload', () => { + const json = { + name: 'billing-tag', + properties: { + tagId: + '/subscriptions/src-sub/resourceGroups/src-rg/providers/Microsoft.ApiManagement/service/src-svc/tags/billing-tag', + displayName: 'Billing Tag', + description: 'Operations related to billing.', + }, + }; + + const result = stripTagDescriptionTagId(json); + + const props = result.properties as Record; + expect(props).not.toHaveProperty('tagId'); + expect(props.displayName).toBe('Billing Tag'); + expect(props.description).toBe('Operations related to billing.'); + }); + + it('returns json unchanged when tagId is absent', () => { + const json = { name: 'tag-desc', properties: { description: 'd' } }; + expect(stripTagDescriptionTagId(json)).toBe(json); + }); + + it('returns json unchanged when properties is missing', () => { + const json = { name: 'tag-desc' }; + expect(stripTagDescriptionTagId(json)).toBe(json); + }); + }); + describe('normalizeApiAuthenticationSettings', () => { it('keeps collections and drops singular fields when collections are non-empty', () => { const json = { From c45c0f5d9f935aca437fb80bc9f4bad73cd7acd8 Mon Sep 17 00:00:00 2001 From: Alexey Zheltov Date: Fri, 9 Oct 2026 18:46:05 +0000 Subject: [PATCH 2/2] test: cover ApiTagDescription tagId stripping through publishResource Addresses review feedback on #313: the helper-level tests alone would not catch a broken or mistyped dispatch condition in publishResource. The new test drives publishResource with an ApiTagDescription descriptor and asserts the payload handed to client.putResource carries no properties.tagId. Fix verified against a live APIM instance: cross-service publish of an API with three tag descriptions succeeded and APIM re-derived tagId against the target service. --- .../unit/services/resource-publisher.test.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/unit/services/resource-publisher.test.ts b/tests/unit/services/resource-publisher.test.ts index 87d87235..528a024f 100644 --- a/tests/unit/services/resource-publisher.test.ts +++ b/tests/unit/services/resource-publisher.test.ts @@ -2099,6 +2099,35 @@ describe('resource-publisher', () => { const json = { name: 'tag-desc' }; expect(stripTagDescriptionTagId(json)).toBe(json); }); + + it('publishResource strips tagId from the ApiTagDescription PUT payload', async () => { + const client = createMockClient(); + const store = createMockStore(); + store.readResource.mockResolvedValue({ + name: 'billing-tag', + properties: { + tagId: + '/subscriptions/src-sub/resourceGroups/src-rg/providers/Microsoft.ApiManagement/service/src-svc/tags/billing-tag', + displayName: 'Billing Tag', + description: 'Operations related to billing.', + }, + }); + + const descriptor: ResourceDescriptor = { + type: ResourceType.ApiTagDescription, + nameParts: ['orders-api', 'billing-tag'], + }; + + const result = await publishResource(client, store, testContext, descriptor, testConfig); + + expect(result.status).toBe('success'); + expect(client.putResource).toHaveBeenCalledTimes(1); + const putPayload = client.putResource.mock.calls[0][2] as Record; + const props = putPayload.properties as Record; + expect(props).not.toHaveProperty('tagId'); + expect(props.displayName).toBe('Billing Tag'); + expect(props.description).toBe('Operations related to billing.'); + }); }); describe('normalizeApiAuthenticationSettings', () => {