diff --git a/CHANGELOG.md b/CHANGELOG.md index 56af482..080b23a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,37 @@ All notable changes to the APIOps CLI are documented in this file. The format is inspired by [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project uses [Semantic Versioning](https://semver.org/) with alpha pre-release tags. +## [1.0.2] - 2026-09-11 + +### Bug Fixes + +- **WSDL schema publishing** - skip importer-generated XSD schemas for WSDL imports, while preserving standalone schemas ([#272](https://github.com/Azure/apiops-cli/pull/272)) +- **Gateway dry runs** - prevent crashes when displaying gateway API association descriptors ([#272](https://github.com/Azure/apiops-cli/pull/272)) +- **Subscription owners** - normalize subscription owner IDs to relative `/users` paths when publishing across services ([#279](https://github.com/Azure/apiops-cli/pull/279)) +- **Portal-created schemas** - recognize 13-digit timestamp schema IDs and skip re-publishing only when the imported specification recreates structurally equivalent components ([#280](https://github.com/Azure/apiops-cli/pull/280)) +- **Sovereign-cloud publishing** - respect the selected cloud endpoint during publish pre-flight checks ([#281](https://github.com/Azure/apiops-cli/pull/281)) + +## [1.0.1] - 2026-09-04 + +### Features + +- **Multi-environment publishing** - publish multiple environment-affixed API revisions to a shared APIM instance, with warnings when generated names exceed APIM limits +- **Filtered publishing parity** - apply resource filters consistently during publish operations +- **Gateway API reconciliation** - reconcile managed API assignments for gateways during publishing + +### Bug Fixes + +- **Revision-aware environment mapping** - preserve revision identity across mapped names, operation reconciliation, authentication overrides, and delete filtering +- **API round-trip reliability** - preserve SOAP APIs and gateway associations while retrying pessimistic-concurrency conflicts and safely handling concurrent deletes +- **Publishing order and cleanup** - publish APIs before products, use desired API manifests for unmatched-resource deletion, and skip missing or in-use associations instead of aborting +- **Publish validation** - reject explicitly empty environment resource scopes before planning deletes, and classify DELETE failures by HTTP status and structured error code +- **Dependency security** - apply npm audit fixes and update `fast-uri` to 3.1.7 + +### Docs & Testing + +- **Filtered-resource publishing guide** - document publishing behavior and examples for resource filters +- **Transitive dependency coverage** - add extraction and publishing tests for transitive dependencies + ## [1.0.0] — 2026-08-27 ### Breaking Changes diff --git a/package-lock.json b/package-lock.json index e478b8c..50c7805 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@azure-tools/apiops-cli", - "version": "1.0.0", + "version": "1.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@azure-tools/apiops-cli", - "version": "1.0.0", + "version": "1.0.2", "license": "MIT", "dependencies": { "@azure/identity": "^4.13.1", diff --git a/package.json b/package.json index ca0e0e4..6e48887 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@azure-tools/apiops-cli", - "version": "1.0.0", + "version": "1.0.2", "schemaVersion": "1", "description": "CLI tool for Azure API Management configuration-as-code", "type": "module", diff --git a/src/clients/apim-client.ts b/src/clients/apim-client.ts index e508483..b88965d 100644 --- a/src/clients/apim-client.ts +++ b/src/clients/apim-client.ts @@ -520,25 +520,15 @@ export class ApimClient implements IApimClient { return true; } catch (error) { - const message = (error as Error).message; - if (message.includes('404')) { - return false; - } - // Resource is still referenced by another entity (e.g. a policy fragment - // used by the service policy). It cannot be deleted until the reference is - // removed; skip it with a warning instead of failing the whole prune. - if (message.includes('is used by the following entities')) { - logger.warn( - `Skipping delete of ${buildResourceLabel(descriptor)}: still referenced by another entity` - ); + if (error instanceof HttpError && error.status === 404) { return false; } // Transient optimistic-concurrency conflict: cascade deletes of related // resources (subscriptions, product/gateway associations) can modify // the resource while its async DELETE is in flight. Retry the DELETE. const isConflict = - message.includes('[PreconditionFailed]') || - (error instanceof HttpError && error.status === 412); + error instanceof HttpError && + (error.status === 412 || error.code === 'PreconditionFailed'); if (isConflict && attempt < ApimClient.DELETE_CONFLICT_RETRIES) { logger.warn( `Delete conflict for ${buildResourceLabel(descriptor)} ` + diff --git a/src/services/env-mapping-validator.ts b/src/services/env-mapping-validator.ts index a5fb158..0b0b992 100644 --- a/src/services/env-mapping-validator.ts +++ b/src/services/env-mapping-validator.ts @@ -65,6 +65,12 @@ export function validateAndBuildEnvMapping( // --- Validate appliesTo entries ---------------------------------------- if (env.appliesTo !== undefined) { + if (env.appliesTo.length === 0) { + throw new Error( + `[publish] environment.appliesTo must contain at least one resource type when specified.` + ); + } + const validTypes = new Set(Object.values(ResourceType)); const unknownTypes: string[] = []; const nonAffixableFound: string[] = []; diff --git a/tests/unit/clients/apim-client.test.ts b/tests/unit/clients/apim-client.test.ts index 9a2a2ef..a8e0d0b 100644 --- a/tests/unit/clients/apim-client.test.ts +++ b/tests/unit/clients/apim-client.test.ts @@ -874,9 +874,59 @@ describe('ApimClient.deleteResource revision and reference handling', () => { expect(url).not.toContain('deleteRevisions=true'); }); - // Bug 2: a policy fragment still referenced by the service policy cannot be - // deleted; the prune should skip it (return false) rather than throw. - it('skips a policy fragment that is still referenced by another entity', async () => { + it('returns false when the initial DELETE reports HTTP 404', async () => { + fetchSpy.mockResolvedValueOnce( + makeResponse(404, { + error: { code: 'ResourceNotFound', message: 'localized-resource-missing' }, + }) + ); + + const deleted = await client.deleteResource(testContext, { + type: ResourceType.PolicyFragment, + nameParts: ['missing-fragment'], + }); + + expect(deleted).toBe(false); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('retries a delete conflict identified by HTTP 412 status', async () => { + fetchSpy + .mockResolvedValueOnce( + makeResponse(412, { + error: { code: 'Conflict', message: 'localized-precondition-failure' }, + }) + ) + .mockResolvedValueOnce(makeResponse(200, {})); + + const deleted = await client.deleteResource(testContext, { + type: ResourceType.Product, + nameParts: ['starter-v2'], + }); + + expect(deleted).toBe(true); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it('retries a delete conflict identified by PreconditionFailed code', async () => { + fetchSpy + .mockResolvedValueOnce( + makeResponse(400, { + error: { code: 'PreconditionFailed', message: 'localized-conflict' }, + }) + ) + .mockResolvedValueOnce(makeResponse(200, {})); + + const deleted = await client.deleteResource(testContext, { + type: ResourceType.Product, + nameParts: ['starter-v2'], + }); + + expect(deleted).toBe(true); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it('propagates a policy fragment ValidationError without a stable discriminator', async () => { const body = { error: { code: 'ValidationError', @@ -887,12 +937,33 @@ describe('ApimClient.deleteResource revision and reference handling', () => { }; fetchSpy.mockResolvedValueOnce(makeResponse(400, body)); - const deleted = await client.deleteResource(testContext, { - type: ResourceType.PolicyFragment, - nameParts: ['global-security-headers'], - }); + await expect( + client.deleteResource(testContext, { + type: ResourceType.PolicyFragment, + nameParts: ['global-security-headers'], + }) + ).rejects.toMatchObject({ status: 400, code: 'ValidationError' }); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('propagates an unrelated ValidationError without retrying', async () => { + fetchSpy.mockResolvedValueOnce( + makeResponse(400, { + error: { + code: 'ValidationError', + message: 'The resource name is invalid.', + }, + }) + ); + + await expect( + client.deleteResource(testContext, { + type: ResourceType.PolicyFragment, + nameParts: ['invalid-fragment'], + }) + ).rejects.toMatchObject({ status: 400, code: 'ValidationError' }); - expect(deleted).toBe(false); expect(fetchSpy).toHaveBeenCalledTimes(1); }); }); diff --git a/tests/unit/services/publish-service.env-mapping.test.ts b/tests/unit/services/publish-service.env-mapping.test.ts index 2e2b78e..b81f4da 100644 --- a/tests/unit/services/publish-service.env-mapping.test.ts +++ b/tests/unit/services/publish-service.env-mapping.test.ts @@ -131,6 +131,18 @@ describe('validateAndBuildEnvMapping', () => { // ─── Error: non-affixable types ────────────────────────────────────────── + it('environment with an empty appliesTo → throws', () => { + const overrides: OverrideConfig = { + environment: { namePrefix: 'dev-', appliesTo: [] }, + }; + const config = makeConfig(overrides); + + expect(() => validateAndBuildEnvMapping(overrides, [], config)).toThrow( + /environment\.appliesTo must contain at least one resource type/ + ); + expect(config.envMapping).toBeUndefined(); + }); + it('appliesTo contains "ServicePolicy" → throws with clear message', () => { const overrides: OverrideConfig = { environment: { diff --git a/tests/unit/services/publish-service.test.ts b/tests/unit/services/publish-service.test.ts index 8860c60..503c3be 100644 --- a/tests/unit/services/publish-service.test.ts +++ b/tests/unit/services/publish-service.test.ts @@ -1345,6 +1345,29 @@ describe('publish-service', () => { expect(result.totalDeletes).toBe(1); }); + it('should reject an empty environment appliesTo before computing delete actions', async () => { + const client = createMockClient(); + const store = createMockStore([]); + const config: PublishConfig = { + service: testContext, + sourceDir: '/source', + dryRun: false, + deleteUnmatched: true, + overrides: { + environment: { namePrefix: 'dev-', appliesTo: [] }, + }, + logLevel: LogLevel.INFO, + }; + + const result = await runPublish(client, store, config); + + expect(result.exitCode).toBe(2); + expect(result.totalDeletes).toBe(0); + expect(computeDeleteActions).not.toHaveBeenCalled(); + expect(client.putResource).not.toHaveBeenCalled(); + expect(client.deleteResource).not.toHaveBeenCalled(); + }); + it('deletes a revisioned API via the base API only, not individual revisions', async () => { const resources = [ { type: ResourceType.Tag, nameParts: ['tag1'] },