Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions src/services/resource-publisher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<string, unknown>
): Record<string, unknown> {
const props = json.properties as Record<string, unknown> | 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<string, unknown>,
descriptor: ResourceDescriptor,
Expand Down
99 changes: 99 additions & 0 deletions tests/unit/services/resource-publisher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
const props = putJson.properties as Record<string, unknown>;
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<string, unknown>;
const props = putJson.properties as Record<string, unknown>;
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<string, unknown>;
const props = putJson.properties as Record<string, unknown>;
// 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();
Expand Down