From 2955fd33b5cbf09422e8b9e8f83501b3e173dc5e Mon Sep 17 00:00:00 2001 From: Alexey Zheltov Date: Tue, 8 Sep 2026 16:35:25 +0000 Subject: [PATCH] fix: normalize subscription ownerId to relative /users path on publish APIM returns ownerId as a full ARM resource path on GET, carrying the source service coordinates. The PUT endpoint expects a relative URL in the format /users/{userId}, so cross-instance publishes sent an ownerId naming the source service. Strip the service ARM prefix the same way normalizeSubscriptionScope handles scope. envMapping affixes are not applied (a user id is not an affixed resource name); already-relative values pass through unchanged. Closes #273 --- src/services/resource-publisher.ts | 32 ++++++ .../unit/services/resource-publisher.test.ts | 99 +++++++++++++++++++ 2 files changed, 131 insertions(+) diff --git a/src/services/resource-publisher.ts b/src/services/resource-publisher.ts index 1c4c17a..efd39fe 100644 --- a/src/services/resource-publisher.ts +++ b/src/services/resource-publisher.ts @@ -429,6 +429,7 @@ export async function publishResource( descriptor.workspace, config.envMapping ); + json = normalizeSubscriptionOwnerId(json); } // ApiRelease: normalize properties.apiId from source ARM path to target ARM path. @@ -1158,6 +1159,37 @@ function normalizeSubscriptionScope( return json; } +/** + * Normalise the `properties.ownerId` field of a Subscription resource. + * + * APIM returns ownerId as a full ARM resource path on GET, e.g.: + * /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.ApiManagement/service/{svc}/users/1 + * + * The PUT endpoint requires a relative URL in the format `/users/{userId}`, + * so a subscription extracted from one instance would otherwise carry the + * source instance's service path. Strip the service ARM prefix like + * normalizeSubscriptionScope does for scope. Unlike scope, envMapping + * affixes must NOT be applied — a user id is not an affixed resource name. + * Already-relative values are left untouched. + */ +function normalizeSubscriptionOwnerId( + json: Record +): Record { + const props = json.properties as Record | undefined; + const ownerId = props?.ownerId; + if (typeof ownerId !== 'string') return json; + + const serviceOwnerMatch = ownerId.match( + /^\/subscriptions\/[^/]+\/resourceGroups\/[^/]+\/providers\/Microsoft\.ApiManagement\/service\/[^/]+(\/users\/[^/]+)$/i + ); + if (!serviceOwnerMatch?.[1]) return json; + + return { + ...json, + properties: { ...props, ownerId: serviceOwnerMatch[1] }, + }; +} + export function applyApiPathPrefix( json: Record, descriptor: ResourceDescriptor, diff --git a/tests/unit/services/resource-publisher.test.ts b/tests/unit/services/resource-publisher.test.ts index c64597b..6add006 100644 --- a/tests/unit/services/resource-publisher.test.ts +++ b/tests/unit/services/resource-publisher.test.ts @@ -1385,6 +1385,105 @@ describe('resource-publisher', () => { expect(client.putResource).toHaveBeenCalledTimes(1); }); + it('should normalize ownerId from source service ARM path to relative /users path', async () => { + const client = createMockClient(); + const store = createMockStore(); + + // ownerId carries the SOURCE service coordinates after extract + const sourceArmPrefix = + '/subscriptions/src-sub/resourceGroups/src-rg/providers/Microsoft.ApiManagement/service/src-apim'; + const targetArmPrefix = + '/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.ApiManagement/service/apim-1'; + + store.readResource.mockResolvedValue({ + name: 'team-a-product-sub', + properties: { + ownerId: `${sourceArmPrefix}/users/1`, + scope: `${targetArmPrefix}/products/starter`, + displayName: 'Team A starter product', + state: 'active', + }, + }); + + const descriptor: ResourceDescriptor = { + type: ResourceType.Subscription, + nameParts: ['team-a-product-sub'], + }; + + await publishResource(client, store, testContext, descriptor, testConfig); + + const putJson = client.putResource.mock.calls[0][2] as Record; + const props = putJson.properties as Record; + expect(props.ownerId).toBe('/users/1'); + }); + + it('should leave already-relative ownerId untouched', async () => { + const client = createMockClient(); + const store = createMockStore(); + + const armScopePrefix = + '/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.ApiManagement/service/apim-1'; + + store.readResource.mockResolvedValue({ + name: 'team-a-product-sub', + properties: { + ownerId: '/users/42', + scope: `${armScopePrefix}/products/starter`, + displayName: 'Team A starter product', + state: 'active', + }, + }); + + const descriptor: ResourceDescriptor = { + type: ResourceType.Subscription, + nameParts: ['team-a-product-sub'], + }; + + await publishResource(client, store, testContext, descriptor, testConfig); + + const putJson = client.putResource.mock.calls[0][2] as Record; + const props = putJson.properties as Record; + expect(props.ownerId).toBe('/users/42'); + }); + + it('should not apply envMapping affixes to normalized ownerId', async () => { + const client = createMockClient(); + const store = createMockStore(); + + const sourceArmPrefix = + '/subscriptions/src-sub/resourceGroups/src-rg/providers/Microsoft.ApiManagement/service/src-apim'; + + store.readResource.mockResolvedValue({ + name: 'team-a-product-sub', + properties: { + ownerId: `${sourceArmPrefix}/users/1`, + scope: `${sourceArmPrefix}/products/starter`, + displayName: 'Team A starter product', + state: 'active', + }, + }); + + const descriptor: ResourceDescriptor = { + type: ResourceType.Subscription, + nameParts: ['team-a-product-sub'], + }; + const config: PublishConfig = { + ...testConfig, + envMapping: buildEnvMapping({ + namePrefix: 'dev-', + appliesTo: [ResourceType.Product], + }), + }; + + await publishResource(client, store, testContext, descriptor, config); + + const putJson = client.putResource.mock.calls[0][2] as Record; + const props = putJson.properties as Record; + // scope gets the affixed product name, ownerId must not be affixed + expect(props.scope).toBe('/products/dev-starter'); + expect(props.ownerId).toBe('/users/1'); + }); + describe('ApiOperation text normalization', () => { it('sets displayName and description to empty string when omitted', async () => { const client = createMockClient();