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();