diff --git a/src/clients/apim-client.ts b/src/clients/apim-client.ts index f1d85bde..2e167ba9 100644 --- a/src/clients/apim-client.ts +++ b/src/clients/apim-client.ts @@ -24,7 +24,8 @@ export class HttpError extends Error { constructor( public readonly status: number, message: string, - public readonly code?: string // APIM error code, e.g. "MethodNotAllowedInPricingTier" + public readonly code?: string, // APIM error code, e.g. "MethodNotAllowedInPricingTier" + public readonly body?: unknown ) { super(message); this.name = 'HttpError'; @@ -41,6 +42,26 @@ export function isLinkAlreadyExistsError(error: unknown): boolean { return error instanceof HttpError && error.status === 409; } +/** Returns true when APIM reports that an association's referenced API/group is absent. */ +export function isAssociationReferenceNotFoundError(error: unknown): boolean { + if ( + !(error instanceof HttpError) || + ![400, 404].includes(error.status) || + !['ValidationError', 'ResourceNotFound'].includes(error.code ?? '') + ) { + return false; + } + + const body = error.body as Record | undefined; + const armError = body?.error as Record | undefined; + const details = Array.isArray(armError?.details) ? armError.details : []; + return details.some((detail) => { + if (typeof detail !== 'object' || detail === null) return false; + const target = (detail as Record).target; + return typeof target === 'string' && ['aid', 'gid'].includes(target.toLowerCase()); + }); +} + export class ApimClient implements IApimClient { private credential: DefaultAzureCredential; private readonly authScope: string; @@ -187,8 +208,9 @@ export class ApimClient implements IApimClient { if (!response.ok) { const errorText = await response.text(); let errorCode: string | undefined; + let errorBody: unknown; try { - const errorBody: unknown = JSON.parse(errorText); + errorBody = JSON.parse(errorText); if ( typeof errorBody === 'object' && errorBody !== null && 'error' in errorBody && @@ -202,7 +224,7 @@ export class ApimClient implements IApimClient { } catch { // Response body is not JSON — no error code available } - throw new HttpError(response.status, `HTTP ${response.status}: ${errorText}`, errorCode); + throw new HttpError(response.status, `HTTP ${response.status}: ${errorText}`, errorCode, errorBody); } return response; diff --git a/src/models/resource-types.ts b/src/models/resource-types.ts index e87fbcc6..6686edb1 100644 --- a/src/models/resource-types.ts +++ b/src/models/resource-types.ts @@ -45,6 +45,13 @@ export enum ResourceType { Workspace = 'Workspace', } +/** + * The built-in "managed" gateway id. It is not returned by `GET /gateways` + * (which lists only self-hosted/custom gateways), but its per-API assignments + * are queryable/manageable at `gateways/managed/apis`. + */ +export const MANAGED_GATEWAY_NAME = 'managed'; + /** * Pure-data descriptor for a single APIM resource type. * diff --git a/src/services/delete-unmatched-service.ts b/src/services/delete-unmatched-service.ts index 0c2c7848..bbb77691 100644 --- a/src/services/delete-unmatched-service.ts +++ b/src/services/delete-unmatched-service.ts @@ -36,7 +36,7 @@ import { isApiRevisionName, } from '../lib/resource-path.js'; import { logger } from '../lib/logger.js'; -import { toCanonicalDescriptor } from './env-mapper.js'; +import { mapDescriptor, toCanonicalDescriptor, toCanonicalName } from './env-mapper.js'; /** * Drop ;rev=N API deletes whose base API (same workspace) is also queued for @@ -123,6 +123,11 @@ export async function computeDeleteActions( // For each resource type in reverse dependency order for (const resourceType of reverseOrder) { + // GatewayApi assignments are reconciled by computeGatewayApiDeleteActions + // below (they require a parent gateway and cannot be listed generically). + if (resourceType === ResourceType.GatewayApi) { + continue; + } try { // List all resources of this type in APIM const apimResources = client.listResources(context, resourceType); @@ -177,9 +182,132 @@ export async function computeDeleteActions( } } + // Gateway → API assignments cannot be enumerated by the generic loop above + // (GatewayApi requires a parent gateway and GET is not supported at the + // collection root). Reconcile them explicitly, scoped to the gateways that + // the local artifacts actually track (including the built-in "managed" + // gateway). This keeps the blast radius limited: gateways with no local + // apis.json are never touched. + const gatewayApiDeletes = await computeGatewayApiDeleteActions( + client, + store, + context, + config, + localDescriptors + ); + // Run association removals first (children before parents). + deleteDescriptors.unshift(...gatewayApiDeletes); + return deleteDescriptors; } +/** + * Reconcile per-gateway API assignments (ResourceType.GatewayApi). + * + * Only gateways that appear as a local GatewayApi artifact are considered, so a + * workspace that does not track gateway associations is left completely + * untouched. The desired API set for each gateway is read from its + * `gateways/{gw}/apis.json` (the artifact store surfaces GatewayApi only as an + * aggregate `nameParts = [gateway]` descriptor, with the API names living in the + * file content), then any deployed assignment not in that desired set is queued + * for deletion (i.e. the API is un-assigned from that gateway). + */ +async function computeGatewayApiDeleteActions( + client: IApimClient, + store: IArtifactStore, + context: ApimServiceContext, + config: PublishConfig, + localDescriptors: ResourceDescriptor[] +): Promise { + const { envMapping } = config; + + // Distinct gateway names that own a local GatewayApi artifact. + const gatewayNames = new Set(); + for (const descriptor of localDescriptors) { + if (descriptor.type === ResourceType.GatewayApi) { + const gatewayName = getNamePart(descriptor.nameParts, 0); + if (gatewayName) { + gatewayNames.add(gatewayName); + } + } + } + + if (gatewayNames.size === 0) { + return []; + } + + const deletes: ResourceDescriptor[] = []; + + for (const gatewayName of gatewayNames) { + const gatewayDescriptor: ResourceDescriptor = { + type: ResourceType.Gateway, + nameParts: [gatewayName], + }; + const deployedGatewayDescriptor = envMapping !== undefined + ? mapDescriptor(gatewayDescriptor, envMapping) + : gatewayDescriptor; + + // Desired API set (canonical names) from the gateway's apis.json artifact. + let desiredApis: Set; + try { + const entries = await store.readAssociation(config.sourceDir, gatewayDescriptor, 'apis'); + desiredApis = new Set(entries.map((entry) => entry.name)); + } catch (error) { + logger.debug( + `[delete-unmatched] Skipping gateway "${gatewayName}" API reconciliation (cannot read desired apis): ${(error as Error).message}` + ); + continue; + } + + try { + for await (const apiJson of client.listResources( + context, + ResourceType.GatewayApi, + deployedGatewayDescriptor + )) { + const apiName = extractResourceName(apiJson); + if (!apiName) { + continue; + } + + const deployedDescriptor: ResourceDescriptor = { + type: ResourceType.GatewayApi, + nameParts: [getNamePart(deployedGatewayDescriptor.nameParts, 0), apiName], + }; + + // Compare the deployed API against the desired set using canonical names + // so env-affixed deployments still match the un-affixed artifacts. + let canonicalApiName = apiName; + if (envMapping !== undefined) { + const canonicalDescriptor = toCanonicalDescriptor(deployedDescriptor, envMapping); + if (canonicalDescriptor === null) { + // Belongs to another environment — do not touch. + continue; + } + const canonicalChildName = toCanonicalName(apiName, ResourceType.Api, envMapping); + if (canonicalChildName === undefined) { + // The gateway can be shared (for example, "managed"), so the child + // API must independently belong to this environment's namespace. + continue; + } + canonicalApiName = canonicalChildName; + } + + if (!desiredApis.has(canonicalApiName)) { + deletes.push(deployedDescriptor); + } + } + } catch (error) { + logger.debug( + `[delete-unmatched] Skipping gateway "${gatewayName}" API reconciliation: ${(error as Error).message}` + ); + continue; + } + } + + return deletes; +} + /** * Create a set of resource keys from descriptors for fast lookup */ diff --git a/src/services/env-mapper.ts b/src/services/env-mapper.ts index 47cc78a8..4ce59e7c 100644 --- a/src/services/env-mapper.ts +++ b/src/services/env-mapper.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { ResourceType } from '../models/resource-types.js'; +import { MANAGED_GATEWAY_NAME, ResourceType } from '../models/resource-types.js'; import type { ResourceDescriptor } from '../models/types.js'; import type { EnvironmentOverride, OverrideConfig } from '../models/config.js'; @@ -180,7 +180,12 @@ function splitRevisionSuffix(name: string, type: ResourceType): { base: string; : { base: name.slice(0, idx), revSuffix: name.slice(idx) }; } +function isManagedGatewayName(name: string, type: ResourceType): boolean { + return type === ResourceType.Gateway && name === MANAGED_GATEWAY_NAME; +} + export function toDeployedName(name: string, type: ResourceType, m: EnvMapping): string { + if (isManagedGatewayName(name, type)) return name; if (!m.appliesTo.has(type)) return name; const { base, revSuffix } = splitRevisionSuffix(name, type); return `${m.prefix}${base}${m.suffix}${revSuffix}`; @@ -192,6 +197,7 @@ export function toDeployedName(name: string, type: ResourceType, m: EnvMapping): * Returns input unchanged when type ∉ appliesTo. */ export function toCanonicalName(deployedName: string, type: ResourceType, m: EnvMapping): string | undefined { + if (isManagedGatewayName(deployedName, type)) return deployedName; if (!m.appliesTo.has(type)) return deployedName; if (!isInEnvNamespace(deployedName, type, m)) return undefined; @@ -207,6 +213,7 @@ export function toCanonicalName(deployedName: string, type: ResourceType, m: Env * When type ∉ appliesTo → returns true (namespace scoping doesn't apply to this type). */ export function isInEnvNamespace(deployedName: string, type: ResourceType, m: EnvMapping): boolean { + if (isManagedGatewayName(deployedName, type)) return true; if (!m.appliesTo.has(type)) return true; const { base } = splitRevisionSuffix(deployedName, type); if (base.length < m.prefix.length + m.suffix.length) return false; diff --git a/src/services/extract-service.ts b/src/services/extract-service.ts index 30225d2d..ca5106d6 100644 --- a/src/services/extract-service.ts +++ b/src/services/extract-service.ts @@ -11,7 +11,7 @@ import { IApimClient } from '../clients/iapim-client.js'; import { IArtifactStore } from '../clients/iartifact-store.js'; import { ExtractConfig, FilterConfig } from '../models/config.js'; import { ApimServiceContext, ResourceDescriptor } from '../models/types.js'; -import { ResourceType } from '../models/resource-types.js'; +import { ResourceType, MANAGED_GATEWAY_NAME } from '../models/resource-types.js'; import { TIER_1_RESOURCES, TIER_2_RESOURCES, @@ -439,7 +439,7 @@ async function extractGatewayAssociations( store: IArtifactStore, context: ApimServiceContext, outputDir: string, - _filter: FilterConfig | undefined, + filter: FilterConfig | undefined, result: ExtractionResult ): Promise { const gatewayResults = result.typeResults.filter((r) => r.type === ResourceType.Gateway); @@ -470,6 +470,35 @@ async function extractGatewayAssociations( } } } + + // The built-in "managed" gateway is not returned by GET /gateways, so extract + // its API assignments explicitly. Recording them lets publish/delete-unmatched + // reconcile managed membership (e.g. an API intentionally removed from managed). + const managedDescriptor: ResourceDescriptor = { + type: ResourceType.Gateway, + nameParts: [MANAGED_GATEWAY_NAME], + }; + if (!shouldIncludeResource(managedDescriptor, filter)) { + return; + } + + try { + const apiNames: string[] = []; + for await (const apiJson of client.listResources(context, ResourceType.GatewayApi, managedDescriptor)) { + const name = apiJson.name as string | undefined; + if (name) { + apiNames.push(name); + } + } + await store.writeAssociation(outputDir, managedDescriptor, 'apis', apiNames); + if (apiNames.length > 0) { + result.totalExtracted++; + logger.info(`Extracted ${apiNames.length} API associations for gateway "${MANAGED_GATEWAY_NAME}"`); + } + } catch (error) { + logger.warn(`Failed to extract API associations for managed gateway: ${(error as Error).message}`); + result.totalErrors++; + } } /** diff --git a/src/services/resource-publisher.ts b/src/services/resource-publisher.ts index 23dc51a1..417b1225 100644 --- a/src/services/resource-publisher.ts +++ b/src/services/resource-publisher.ts @@ -11,7 +11,7 @@ import type { IApimClient } from '../clients/iapim-client.js'; import type { IArtifactStore } from '../clients/iartifact-store.js'; import type { ApimServiceContext, ResourceDescriptor } from '../models/types.js'; import type { PublishConfig } from '../models/config.js'; -import { ResourceType, RESOURCE_TYPE_METADATA } from '../models/resource-types.js'; +import { ResourceType, RESOURCE_TYPE_METADATA, MANAGED_GATEWAY_NAME } from '../models/resource-types.js'; import { applyOverrides } from './override-merger.js'; import { checkKeyVaultSecretAccess } from './keyvault-checker.js'; import { getNamePart } from '../lib/resource-path.js'; @@ -19,7 +19,7 @@ import { isAutoGeneratedId } from '../lib/auto-generated.js'; import { isWorkspaceScope, buildLinkPayload } from '../lib/workspace-link.js'; import { logger } from '../lib/logger.js'; import { REDACTION_MARKER } from './secret-redactor.js'; -import { isLinkAlreadyExistsError } from '../clients/apim-client.js'; +import { isAssociationReferenceNotFoundError, isLinkAlreadyExistsError } from '../clients/apim-client.js'; import type { OverrideConfig, OverrideSection } from '../models/config.js'; import { buildResourceLabel } from '../lib/resource-uri.js'; import { mapDescriptor, toDeployedName } from './env-mapper.js'; @@ -208,6 +208,13 @@ export async function publishResource( config: PublishConfig ): Promise { try { + // The built-in "managed" gateway cannot be created/updated as a resource; + // only its API assignments are manageable. Skip any managed Gateway resource + // PUT (its GatewayApi associations are still published via the branch below). + if (descriptor.type === ResourceType.Gateway && getNamePart(descriptor.nameParts, 0) === MANAGED_GATEWAY_NAME) { + return { descriptor, status: 'skipped', action: 'noop' }; + } + // Handle association types (ProductApi, ProductGroup, GatewayApi) const associationType = ASSOCIATION_TYPES.get(descriptor.type); if (associationType) { @@ -527,9 +534,20 @@ async function publishAssociation( await client.putResource(context, assocDescriptor, {}); } catch (error) { // 409 means the link already exists — desired state is in place. - if (!isLinkAlreadyExistsError(error)) { - throw error; + if (isLinkAlreadyExistsError(error)) { + continue; + } + // The referenced API/group is absent on the target (filtered out or + // failed to publish). Skip this single link with a warning instead of + // aborting the whole association, so other present entries still link. + if (isAssociationReferenceNotFoundError(error)) { + logger.warn( + `Skipping ${associationType} association '${entry.name}' on ` + + `'${getNamePart(descriptor.nameParts, 0)}': referenced resource not found on target` + ); + continue; } + throw error; } } diff --git a/tests/unit/services/delete-unmatched-service.test.ts b/tests/unit/services/delete-unmatched-service.test.ts index 881534e2..615b567e 100644 --- a/tests/unit/services/delete-unmatched-service.test.ts +++ b/tests/unit/services/delete-unmatched-service.test.ts @@ -13,6 +13,7 @@ import { ResourceType } from '../../../src/models/resource-types.js'; import { ApimServiceContext, ResourceDescriptor } from '../../../src/models/types.js'; import { PublishConfig } from '../../../src/models/config.js'; import { LogLevel } from '../../../src/lib/logger.js'; +import { buildEnvMapping } from '../../../src/services/env-mapper.js'; function createMockClient(apimResources: Map[]> = new Map()) { return { @@ -229,6 +230,124 @@ describe('delete-unmatched-service', () => { nameParts: ['api1'], }); }); + + it('should un-assign a stale managed gateway API not present in artifacts', async () => { + const apimResources = new Map[]>([ + [ResourceType.GatewayApi, [{ name: 'api-keep' }, { name: 'api-stale' }]], + ]); + + // The store surfaces GatewayApi as an aggregate descriptor (nameParts = + // [gateway]); the desired API names live in apis.json (readAssociation). + const localDescriptors: ResourceDescriptor[] = [ + { type: ResourceType.GatewayApi, nameParts: ['managed'] }, + ]; + + const client = createMockClient(apimResources); + const store = createMockStore(localDescriptors); + store.readAssociation = vi.fn().mockResolvedValue([{ name: 'api-keep' }]); + + const result = await computeDeleteActions(client, store, testContext, testConfig); + + const gatewayApiDeletes = result.filter((d) => d.type === ResourceType.GatewayApi); + expect(gatewayApiDeletes).toHaveLength(1); + expect(gatewayApiDeletes[0]).toMatchObject({ + type: ResourceType.GatewayApi, + nameParts: ['managed', 'api-stale'], + }); + }); + + it('should keep a desired gateway API even when it is also being published', async () => { + const apimResources = new Map[]>([ + [ResourceType.GatewayApi, [{ name: 'webapitest' }]], + ]); + + const localDescriptors: ResourceDescriptor[] = [ + { type: ResourceType.GatewayApi, nameParts: ['shgw-UAE-01'] }, + ]; + + const client = createMockClient(apimResources); + const store = createMockStore(localDescriptors); + store.readAssociation = vi.fn().mockResolvedValue([ + { name: 'customermanagementservice' }, + { name: 'swagger-petstore' }, + { name: 'webapitest' }, + ]); + + const result = await computeDeleteActions(client, store, testContext, testConfig); + + expect(result.filter((d) => d.type === ResourceType.GatewayApi)).toHaveLength(0); + }); + + it('should list custom gateway APIs using the deployed parent name', async () => { + const localDescriptors: ResourceDescriptor[] = [ + { type: ResourceType.GatewayApi, nameParts: ['custom-gateway'] }, + ]; + const listedParents: Array = []; + const client = createMockClient(); + client.listResources = async function* (_ctx: ApimServiceContext, type: ResourceType, parent?: ResourceDescriptor) { + if (type === ResourceType.GatewayApi) { + listedParents.push(parent); + if (parent?.nameParts[0] === 'dev-custom-gateway') { + yield { name: 'dev-api-stale' }; + } + } + }; + const store = createMockStore(localDescriptors); + store.readAssociation = vi.fn().mockResolvedValue([]); + const envMapping = buildEnvMapping({ + namePrefix: 'dev-', + appliesTo: [ResourceType.Gateway, ResourceType.Api], + }); + + const result = await computeDeleteActions(client, store, testContext, { ...testConfig, envMapping }); + + expect(listedParents).toContainEqual({ + type: ResourceType.Gateway, + nameParts: ['dev-custom-gateway'], + }); + expect(result).toContainEqual({ + type: ResourceType.GatewayApi, + nameParts: ['dev-custom-gateway', 'dev-api-stale'], + }); + }); + + it('should delete all in-scope managed gateway APIs for an empty desired set without touching other environments', async () => { + const apimResources = new Map[]>([ + [ResourceType.GatewayApi, [ + { name: 'dev-api-one' }, + { name: 'dev-api-two' }, + { name: 'prod-api' }, + ]], + ]); + const localDescriptors: ResourceDescriptor[] = [ + { type: ResourceType.GatewayApi, nameParts: ['managed'] }, + ]; + const client = createMockClient(apimResources); + const store = createMockStore(localDescriptors); + store.readAssociation = vi.fn().mockResolvedValue([]); + const envMapping = buildEnvMapping({ namePrefix: 'dev-' }); + + const result = await computeDeleteActions(client, store, testContext, { ...testConfig, envMapping }); + + expect(result.filter((descriptor) => descriptor.type === ResourceType.GatewayApi)).toEqual([ + { type: ResourceType.GatewayApi, nameParts: ['managed', 'dev-api-one'] }, + { type: ResourceType.GatewayApi, nameParts: ['managed', 'dev-api-two'] }, + ]); + }); + + it('should not touch any gateway assignments when artifacts track no gateways', async () => { + const apimResources = new Map[]>([ + [ResourceType.GatewayApi, [{ name: 'api-a' }, { name: 'api-b' }]], + ]); + + // No local GatewayApi artifacts → reconciliation must be a no-op. + const client = createMockClient(apimResources); + const store = createMockStore([]); + + const result = await computeDeleteActions(client, store, testContext, testConfig); + + expect(result.filter((d) => d.type === ResourceType.GatewayApi)).toHaveLength(0); + }); }); describe('filterRevisionDeletesHandledByBaseApi', () => { diff --git a/tests/unit/services/env-mapper.test.ts b/tests/unit/services/env-mapper.test.ts index 12533cd2..df1cda88 100644 --- a/tests/unit/services/env-mapper.test.ts +++ b/tests/unit/services/env-mapper.test.ts @@ -12,6 +12,7 @@ import { buildEnvMappingFromOverrides, toDeployedName, toCanonicalName, + toCanonicalDescriptor, isInEnvNamespace, mapDescriptor, type EnvironmentOverride, @@ -393,6 +394,21 @@ describe('env-mapper', () => { expect(result).toEqual({ type: ResourceType.GatewayApi, nameParts: ['my-gw', 'dev-petstore'] }); }); + it('GatewayApi: preserves the managed gateway when Gateway is explicitly affixed', () => { + const mapping = prefixMapping('dev-', [ResourceType.Gateway, ResourceType.Api]); + const descriptor: ResourceDescriptor = { type: ResourceType.GatewayApi, nameParts: ['managed', 'petstore'] }; + + expect(mapDescriptor(descriptor, mapping)).toEqual({ + type: ResourceType.GatewayApi, + nameParts: ['managed', 'dev-petstore'], + }); + expect(toCanonicalDescriptor({ + type: ResourceType.GatewayApi, + nameParts: ['managed', 'dev-petstore'], + }, mapping)).toEqual(descriptor); + expect(isInEnvNamespace('managed', ResourceType.Gateway, mapping)).toBe(true); + }); + it('ServicePolicy: no nameParts, returned unchanged', () => { const d: ResourceDescriptor = { type: ResourceType.ServicePolicy, nameParts: [] }; expect(mapDescriptor(d, m)).toEqual(d); diff --git a/tests/unit/services/extract-service.test.ts b/tests/unit/services/extract-service.test.ts index f846aff0..53c23e84 100644 --- a/tests/unit/services/extract-service.test.ts +++ b/tests/unit/services/extract-service.test.ts @@ -604,6 +604,83 @@ describe('extract-service', () => { ); }); + it('should extract managed gateway API associations', async () => { + // Managed gateway is NOT returned by GET /gateways, only its apis are queryable. + const client = createMockClient(); + client.listResources = async function* (_ctx: ApimServiceContext, type: ResourceType, parent?: ResourceDescriptor) { + if (type === ResourceType.GatewayApi && parent?.nameParts[0] === 'managed') { + yield { name: 'managed-api-1' }; + yield { name: 'managed-api-2' }; + } + }; + + const store = createMockStore(); + + const config: ExtractConfig = { + service: testContext, + outputDir: '/output', + includeTransitive: false, + logLevel: LogLevel.INFO, + }; + + await runExtraction(client, store, config); + + expect(store.writeAssociation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ type: ResourceType.Gateway, nameParts: ['managed'] }), + 'apis', + expect.arrayContaining(['managed-api-1', 'managed-api-2']) + ); + }); + + it('should write an empty managed gateway association when it has no apis', async () => { + const client = createMockClient(); + const store = createMockStore(); + + const result = await runExtraction(client, store, { + service: testContext, + outputDir: '/output', + includeTransitive: false, + logLevel: LogLevel.INFO, + }); + + expect(store.writeAssociation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ nameParts: ['managed'] }), + 'apis', + [] + ); + expect(result.totalExtracted).toBe(0); + }); + + it('should not extract the managed gateway when excluded by the gateway filter', async () => { + let managedGatewayListed = false; + const client = createMockClient(); + client.listResources = async function* (_ctx: ApimServiceContext, type: ResourceType, parent?: ResourceDescriptor) { + if (type === ResourceType.GatewayApi && parent?.nameParts[0] === 'managed') { + managedGatewayListed = true; + yield { name: 'managed-api' }; + } + }; + const store = createMockStore(); + + await runExtraction(client, store, { + service: testContext, + outputDir: '/output', + filter: { gateways: [] }, + includeTransitive: false, + logLevel: LogLevel.INFO, + }); + + expect(managedGatewayListed).toBe(false); + expect(store.writeAssociation).not.toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ nameParts: ['managed'] }), + 'apis', + expect.anything() + ); + }); + it('should handle gateway association extraction error gracefully', async () => { const client = createMockClient({ [ResourceType.Gateway]: [ diff --git a/tests/unit/services/resource-publisher.test.ts b/tests/unit/services/resource-publisher.test.ts index 0431a11b..fe76173b 100644 --- a/tests/unit/services/resource-publisher.test.ts +++ b/tests/unit/services/resource-publisher.test.ts @@ -16,6 +16,8 @@ import { PublishConfig } from '../../../src/models/config.js'; import { KeyVaultAccessError } from '../../../src/services/keyvault-checker.js'; import { LogLevel } from '../../../src/lib/logger.js'; import { REDACTION_MARKER } from '../../../src/services/secret-redactor.js'; +import { buildEnvMapping } from '../../../src/services/env-mapper.js'; +import { HttpError } from '../../../src/clients/apim-client.js'; // Mock keyvault-checker so resource-publisher tests don't need an Azure environment const mockCheckKeyVaultSecretAccess = vi.fn().mockResolvedValue(undefined); @@ -98,6 +100,24 @@ describe('resource-publisher', () => { expect(client.putResource).not.toHaveBeenCalled(); }); + it('should skip the built-in managed gateway resource without a PUT', async () => { + const client = createMockClient(); + const store = createMockStore(); + store.readResource.mockResolvedValue({ name: 'managed', properties: {} }); + + const descriptor: ResourceDescriptor = { + type: ResourceType.Gateway, + nameParts: ['managed'], + }; + + const result = await publishResource(client, store, testContext, descriptor, testConfig); + + expect(result.status).toBe('skipped'); + expect(result.action).toBe('noop'); + expect(client.putResource).not.toHaveBeenCalled(); + }); + + it('should return success and call putResource on success', async () => { const client = createMockClient(); const store = createMockStore(); @@ -354,6 +374,99 @@ describe('resource-publisher', () => { ); }); + it('should preserve the managed GatewayApi parent under environment mapping', async () => { + const client = createMockClient(); + const store = createMockStore(); + store.readAssociation.mockResolvedValue([{ name: 'api-1' }]); + const envMapping = buildEnvMapping({ + namePrefix: 'dev-', + appliesTo: [ResourceType.Gateway, ResourceType.Api], + }); + + const result = await publishResource( + client, + store, + testContext, + { type: ResourceType.GatewayApi, nameParts: ['managed'] }, + { ...testConfig, envMapping } + ); + + expect(result.status).toBe('success'); + expect(client.putResource).toHaveBeenCalledWith( + testContext, + expect.objectContaining({ type: ResourceType.GatewayApi, nameParts: ['managed', 'dev-api-1'] }), + {} + ); + }); + + it('skips a GatewayApi link when the referenced API is not on the target and keeps going', async () => { + const client = createMockClient(); + const store = createMockStore(); + store.readAssociation.mockResolvedValue([{ name: 'missing-api' }, { name: 'present-api' }]); + client.putResource = vi.fn().mockImplementation(async (_ctx: unknown, d: ResourceDescriptor) => { + if (d.nameParts[1] === 'missing-api') { + const body = { + error: { + code: 'ValidationError', + details: [{ target: 'aid', message: 'The referenced API does not exist' }], + }, + }; + throw new HttpError( + 400, + `HTTP 400: ${JSON.stringify(body)}`, + 'ValidationError', + body + ); + } + return {}; + }); + + const descriptor: ResourceDescriptor = { + type: ResourceType.GatewayApi, + nameParts: ['my-gateway'], + }; + + const result = await publishResource(client, store, testContext, descriptor, testConfig); + + // A missing API must not abort the whole association. + expect(result.status).toBe('success'); + // The present API is still linked. + expect(client.putResource).toHaveBeenCalledWith( + testContext, + expect.objectContaining({ type: ResourceType.GatewayApi, nameParts: ['my-gateway', 'present-api'] }), + {} + ); + }); + + it('should fail a GatewayApi association for an unrelated validation error', async () => { + const client = createMockClient(); + const store = createMockStore(); + store.readAssociation.mockResolvedValue([{ name: 'invalid-api' }]); + const body = { + error: { + code: 'ValidationError', + details: [{ target: 'gatewayId', message: 'The gateway is invalid' }], + }, + }; + client.putResource.mockRejectedValue(new HttpError( + 400, + `HTTP 400: ${JSON.stringify(body)}`, + 'ValidationError', + body + )); + + const result = await publishResource( + client, + store, + testContext, + { type: ResourceType.GatewayApi, nameParts: ['my-gateway'] }, + testConfig + ); + + expect(result.status).toBe('failed'); + expect(result.error).toBeInstanceOf(HttpError); + }); + it('should strip properties.value from KeyVault-backed NamedValue PUT payload', async () => { const client = createMockClient(); const store = createMockStore();