diff --git a/README.md b/README.md index aec7ad71..dcbac204 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,11 @@ apiops init \ | `--client-secret ` | `AZURE_CLIENT_SECRET` env var | Service principal secret | | `--tenant-id ` | `AZURE_TENANT_ID` env var | Azure AD tenant ID | +## Guides + +- [Environment overrides](docs/guides/environment-overrides.md) — per-environment property overrides (backend URLs, secrets, credentials) +- [Multi-environment publish to a single APIM instance](docs/guides/multi-environment-shared-apim.md) — publish dev/qa/prod to a shared APIM using name prefixes + ## Changelog See [CHANGELOG.md](./CHANGELOG.md) for release notes and the list of changes in each version. To see what has changed diff --git a/docs/guides/environment-overrides.md b/docs/guides/environment-overrides.md index 6dc3a359..30ebcba7 100644 --- a/docs/guides/environment-overrides.md +++ b/docs/guides/environment-overrides.md @@ -42,6 +42,14 @@ The schema provides: - Validation of the override structure (name + properties format) - Inline documentation including token substitution syntax +## Multi-environment on a single APIM instance + +Override files support an optional top-level `environment:` block that enables publishing multiple environments (dev, qa, prod) to a **single shared APIM instance**. When present, resource names are prefixed and/or suffixed at publish time so dev, qa, and prod resources coexist without colliding. Cross-references in policy XML — `{{namedValue}}` tokens, fragment IDs, backend IDs, subscription scopes — are rewritten automatically to use the affixed names. + +Override entries always use **canonical (unprefixed) names** matching the artifact files on disk, regardless of whether an `environment:` block is present. The tool applies the affix at publish time; you never write prefixed names in override files or artifact files. + +For full details — including the default and opt-in affixed types, `apiPathPrefix` precedence, `--delete-unmatched` namespace scoping, and a worked three-environment pipeline example — see [Multi-environment on shared APIM](multi-environment-shared-apim.md). + ## Override file format (APIOps Toolkit-compatible) `apiops-cli` uses the [APIOps Toolkit](https://github.com/Azure/apiops) override layout: diff --git a/docs/guides/multi-environment-shared-apim.md b/docs/guides/multi-environment-shared-apim.md new file mode 100644 index 00000000..b51f1acf --- /dev/null +++ b/docs/guides/multi-environment-shared-apim.md @@ -0,0 +1,441 @@ +# Multi-Environment Publish to a Shared APIM Instance + +This guide covers how to publish multiple environments (dev, qa, prod) to a **single Azure API Management instance** using the `environment:` block in your override files. + +For standard single-APIM-per-env deployments, you do not need this guide — see [Environment Overrides](environment-overrides.md). + +## When to use this + +Use the shared-APIM pattern when: + +- You want to reduce cost by running dev, qa, and prod APIs on one APIM instance. +- You need to demo or test multiple environments side-by-side on the same gateway endpoint. +- You are operating a Developer or Consumption tier APIM where creating per-environment instances is impractical. + +**Do not use this pattern when:** + +- Each environment has its own dedicated APIM instance (the default, simpler approach). +- Your security or compliance policy requires full network isolation between environments. +- You need independent SLA guarantees per environment — a shared APIM is a shared failure domain. + +The trade-off is straightforward: shared APIM saves cost and consolidates gateway management, but all environments compete for the same capacity and any APIM-level outage affects all of them simultaneously. + +## How it works + +All artifact files on disk use **canonical names** — the bare names extracted from your source-of-truth APIM (e.g., `petstore-api`, `my-backend`). At publish time, `apiops publish` reads the `environment:` block from your override file and applies a prefix and/or suffix to every resource name before sending it to APIM. + +For example, with `namePrefix: "dev-"`, the canonical artifact `petstore-api` is deployed as `dev-petstore-api`. The prod publish with `namePrefix: "prod-"` deploys the same artifact as `prod-petstore-api`. Both coexist on the shared APIM without colliding. + +Cross-references inside resources — policy `{{token}}` references, fragment IDs, backend IDs, subscription scopes, API release API IDs, and association links — are rewritten automatically to use the affixed names. You write your artifacts and overrides using canonical names throughout; the tool handles renaming at the boundary. + +## Prerequisites + +You need a **canonical source-of-truth APIM** — a dedicated APIM instance (or clean committed artifacts) that contains your API configuration with bare, unprefixed names. Extract from this instance to produce your artifact files. + +> **Warning: Do not extract from the shared APIM.** +> Extracting from a shared APIM produces mixed-environment artifacts — you will get `dev-petstore-api`, `prod-petstore-api`, and similar prefixed names in your artifact directory. Those prefixed names become the canonical names in your artifacts, and the affix logic will then double-prefix them on the next publish (e.g., `dev-dev-petstore-api`). Always extract from a dedicated source-of-truth APIM with clean, unprefixed names. + +```bash +# Extract from your source-of-truth APIM (dedicated, unprefixed) +apiops extract \ + --resource-group rg-source \ + --service-name apim-source \ + --output ./apim-artifacts +``` + +## Setup + +Add an `environment:` block to each environment's override file. The block is a top-level sibling of the resource sections (`apis`, `backends`, etc.). + +```yaml +# configuration.dev.yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/apiops-cli/main/schemas/v1/override-config.schema.json +environment: + namePrefix: "dev-" + apiPathPrefix: "dev/" + +apis: + - name: petstore-api # canonical name — no prefix here + properties: + serviceUrl: https://petstore-dev.contoso.com + +backends: + - name: petstore-backend # canonical name + properties: + url: https://petstore-dev.contoso.com +``` + +```yaml +# configuration.prod.yaml +environment: + namePrefix: "prod-" + apiPathPrefix: "prod/" + +apis: + - name: petstore-api # same canonical name + properties: + serviceUrl: https://petstore.contoso.com + +backends: + - name: petstore-backend + properties: + url: https://petstore.contoso.com +``` + +All override `name` values must be the **canonical (unprefixed) name**, matching the artifact on disk. The prefix is applied by the tool at publish time — not by you in the override file. + +### Available fields + +| Field | Type | Description | +|---|---|---| +| `namePrefix` | `string` | Prepended to resource names for types in `appliesTo`. | +| `nameSuffix` | `string` | Appended to resource names for types in `appliesTo`. | +| `appliesTo` | `string[]` | Resource type names to affix. Omit to use the default set. | +| `apiPathPrefix` | `string` | Prepended to each API's `properties.path`. Not applied when a per-API `path` override is present. | + +You can use `namePrefix`, `nameSuffix`, or both simultaneously. For example, `namePrefix: "dev-"` and `nameSuffix: "-v2"` produces `dev-petstore-api-v2`. + +> **⚠️ At least one name affix is required.** If you specify an `environment:` block, you **must** set at least one of `namePrefix` or `nameSuffix`. A block with only `apiPathPrefix` (or with no fields set) is rejected at publish time with an error. +> +> **Why:** Without a name affix, resource names collide across environments on the shared APIM instance. Worse, `apiops publish --delete-unmatched` would treat every other environment's resources as stale and delete them — silently destroying the shared instance. Path-only isolation is intentionally not supported for this reason. +> +> **Use a separator character** in your affix (e.g., `"dev-"` not `"dev"`, or `"-dev"` not `"dev"`). This prevents partial-name matches during `--delete-unmatched` — a `namePrefix` of `"dev"` (no separator) would match `developer-*`, `development-*`, and any other resources whose canonical names happen to start with `"dev"`. +> +> If you don't need a name affix, remove the `environment:` block entirely and publish under canonical names (single-environment mode). + +## What gets affixed + +### Default affixed types + +When `appliesTo` is omitted, the following resource types receive the prefix/suffix: + +| Resource type | `appliesTo` value | +|---|---| +| Api | `Api` | +| Product | `Product` | +| NamedValue | `NamedValue` | +| Backend | `Backend` | +| Logger | `Logger` | +| PolicyFragment | `PolicyFragment` | +| VersionSet | `VersionSet` | +| Tag | `Tag` | +| Group | `Group` | +| Subscription | `Subscription` | +| Workspace | `Workspace` | + +### Opt-in types + +The following types are **not** affixed by default but can be added to `appliesTo`: + +| Resource type | `appliesTo` value | Notes | +|---|---|---| +| Gateway | `Gateway` | Self-hosted gateways. | +| Diagnostic | `Diagnostic` | Service-level diagnostics (e.g., `applicationinsights`). Usually a singleton per APIM — affix carefully. | +| GlobalSchema | `GlobalSchema` | Service-wide shared schemas. | +| PolicyRestriction | `PolicyRestriction` | Policy restriction rules. | +| Documentation | `Documentation` | API documentation entries. | + +### Types that are never affixed + +Some resource types cannot be affixed because they are singletons, derived from their parent, or association links whose identity is fully determined by their parent names: + +- **Singletons:** `ServicePolicy`, `ApiPolicy`, `ProductPolicy`, `ApiOperationPolicy`, `GraphQLResolverPolicy`, `ApiWiki`, `ProductWiki`, `McpServer` +- **Association children** (affixed via parent): `ProductApi`, `ProductGroup`, `ProductTag`, `GatewayApi`, `ApiTag`, `ApiDiagnostic` +- **Sub-resource children** (affixed via parent API): `ApiOperation`, `ApiSchema`, `ApiRelease`, `ApiTagDescription`, `GraphQLResolver` + +Including any of these in `appliesTo` is a validation error. + +## Customising `appliesTo` + +### Adding opt-in types + +```yaml +environment: + namePrefix: "dev-" + appliesTo: + - Api + - Product + - NamedValue + - Backend + - Logger + - PolicyFragment + - VersionSet + - Tag + - Group + - Subscription + - Workspace + - Gateway # opt-in: affix self-hosted gateway names too +``` + +### Narrowing the default set + +To affix only APIs and backends (and nothing else): + +```yaml +environment: + namePrefix: "dev-" + appliesTo: + - Api + - Backend +``` + +Any type not listed is left with its canonical name on the shared APIM, which means all environments share the same named value, product, etc. Only do this intentionally — shared resources must be compatible across environments. + +## Precedence rules + +### Resource names + +An explicit `name` in an override entry **always** refers to the canonical (unprefixed) name. The `environment:` block affix is applied after override properties are merged. You never write prefixed names in override files. + +### API path (`apiPathPrefix`) + +- `apiPathPrefix` is applied to `properties.path` for every API in `appliesTo`. +- If a specific API entry in the override file includes a `properties.path` value, that **explicit override wins** and `apiPathPrefix` is not applied to that API. +- The two do not stack — an explicit `path` override completely replaces the prefix behavior for that API. + +```yaml +environment: + namePrefix: "dev-" + apiPathPrefix: "dev/" # applied to all APIs by default + +apis: + - name: petstore-api + properties: + serviceUrl: https://petstore-dev.contoso.com + # No path override → apiPathPrefix applies → deployed path is "dev/petstore" + + - name: legacy-api + properties: + serviceUrl: https://legacy-dev.contoso.com + path: "internal/legacy" # explicit override → apiPathPrefix NOT applied +``` + +### NamedValue displayName + +NamedValues have both a `name` (resource key) and a `displayName` (the `{{token}}` identifier used in policy XML). When `NamedValue` is in `appliesTo`: + +- Both the resource name **and** the displayName receive the affix. +- This ensures `{{my-token}}` in a policy becomes `{{dev-my-token}}` for dev — cross-env policy isolation is maintained automatically. +- If you set an explicit `displayName` in a NamedValue override entry, that value is used as-is (no affix applied to it). + +## Cross-reference rewrites (automatic) + +When `appliesTo` includes the relevant types, `apiops publish` rewrites the following references in artifacts before sending them to APIM. You do not need to update artifact files or override files manually. + +| Reference | Location | How it is rewritten | +|---|---|---| +| `{{namedValueToken}}` | Policy XML | Token rewritten to affixed displayName when `NamedValue ∈ appliesTo` and the named value artifact exists. Unknown tokens pass through unchanged. | +| `fragment-id="..."` | Policy XML `` | Fragment ID rewritten when `PolicyFragment ∈ appliesTo`. | +| `backend-id="..."` | Policy XML `` | Backend ID rewritten when `Backend ∈ appliesTo`. | +| Subscription `scope` | ARM subscription properties | Trailing segment rewritten when the referenced type (Api or Product) is in `appliesTo`. | +| ApiRelease `apiId` | ARM release properties | Api ID segment rewritten when `Api ∈ appliesTo`. | +| ProductApi association | ARM resource path | Both product and API name segments rewritten per `appliesTo`. | +| ProductGroup association | ARM resource path | Both product and group name segments rewritten per `appliesTo`. | +| GatewayApi association | ARM resource path | Gateway and API name segments rewritten per `appliesTo`. | +| API `properties.path` | ARM API properties | Prefix applied unless explicit path override present (see [Precedence rules](#precedence-rules)). | + +## `--delete-unmatched` safety + +When you run `apiops publish --delete-unmatched` against a shared APIM, the delete pass is **namespace-scoped**: only resources whose name matches the current environment's prefix/suffix pattern are candidates for deletion. + +A publish with `namePrefix: "dev-"` will only delete resources that start with `dev-`. Resources named `prod-*`, `qa-*`, or any other prefix are invisible to the delete pass and are never touched. + +> **Warning: Do not drop the prefix mid-life.** +> +> If you initially deploy an environment with `namePrefix: "dev-"` and later remove or change the prefix in your override file, `--delete-unmatched` can no longer identify which resources belong to that environment. It will see all resources without the old prefix as candidates for deletion, potentially removing resources from other environments. +> +> If you need to rename or remove the prefix, do so in a controlled migration: first remove `--delete-unmatched` from your pipeline, rename/remove the prefix, republish all resources, then manually delete the old prefixed resources. + +## Worked end-to-end example + +This example shows a three-environment pipeline (dev, qa, prod) all publishing to a single APIM instance named `apim-shared`. + +### Repository structure + +```text +project/ +├── apim-artifacts/ +│ ├── apis/ +│ │ └── petstore-api/ +│ │ └── apiInformation.json +│ ├── backends/ +│ │ └── petstore-backend/ +│ │ └── backendInformation.json +│ └── namedValues/ +│ └── api-key/ +│ └── namedValueInformation.json +├── configuration.dev.yaml +├── configuration.qa.yaml +└── configuration.prod.yaml +``` + +### Override files + +```yaml +# configuration.dev.yaml +environment: + namePrefix: "dev-" + apiPathPrefix: "dev/" + +apis: + - name: petstore-api + properties: + serviceUrl: https://petstore-dev.contoso.com + +backends: + - name: petstore-backend + properties: + url: https://petstore-dev.contoso.com + +namedValues: + - name: api-key + properties: + value: "{#[DEV_API_KEY]#}" +``` + +```yaml +# configuration.qa.yaml +environment: + namePrefix: "qa-" + apiPathPrefix: "qa/" + +apis: + - name: petstore-api + properties: + serviceUrl: https://petstore-qa.contoso.com + +backends: + - name: petstore-backend + properties: + url: https://petstore-qa.contoso.com + +namedValues: + - name: api-key + properties: + value: "{#[QA_API_KEY]#}" +``` + +```yaml +# configuration.prod.yaml +environment: + namePrefix: "prod-" + apiPathPrefix: "prod/" + +apis: + - name: petstore-api + properties: + serviceUrl: https://petstore.contoso.com + +backends: + - name: petstore-backend + properties: + url: https://petstore.contoso.com + +namedValues: + - name: api-key + properties: + value: "{#[PROD_API_KEY]#}" +``` + +### Extract from source-of-truth + +```bash +apiops extract \ + --resource-group rg-source \ + --service-name apim-source \ + --output ./apim-artifacts +``` + +### Publish each environment + +```bash +# Dev +apiops publish \ + --resource-group rg-shared \ + --service-name apim-shared \ + --source ./apim-artifacts \ + --overrides configuration.dev.yaml \ + --delete-unmatched + +# QA +apiops publish \ + --resource-group rg-shared \ + --service-name apim-shared \ + --source ./apim-artifacts \ + --overrides configuration.qa.yaml \ + --delete-unmatched + +# Prod +apiops publish \ + --resource-group rg-shared \ + --service-name apim-shared \ + --source ./apim-artifacts \ + --overrides configuration.prod.yaml \ + --delete-unmatched +``` + +### What lands on the shared APIM + +After all three publishes, the shared APIM contains: + +| Resource type | Dev | QA | Prod | +|---|---|---|---| +| API | `dev-petstore-api` (path: `dev/petstore`) | `qa-petstore-api` (path: `qa/petstore`) | `prod-petstore-api` (path: `prod/petstore`) | +| Backend | `dev-petstore-backend` | `qa-petstore-backend` | `prod-petstore-backend` | +| NamedValue | `dev-api-key` | `qa-api-key` | `prod-api-key` | + +Each environment's resources are fully isolated by namespace. A request to `https://apim-shared.azure-api.net/dev/petstore/pets` routes to the dev backend; `prod/petstore/pets` routes to prod. + +## Troubleshooting + +### Policy token `{{myThing}}` was not rewritten + +**Cause:** Either `NamedValue` is not in `appliesTo`, or the artifact for `myThing` does not exist in the artifact directory. + +**Fix:** Verify that: +1. `NamedValue` is included in `appliesTo` (or you are using the default set, which includes it). +2. A `namedValues/myThing/` artifact directory exists in your `--source` path. +3. The token name in the policy exactly matches the NamedValue's `displayName` in the artifact (case-sensitive). + +Unknown tokens (no matching artifact) pass through unchanged and are left as-is in the deployed policy. + +### Publish failed with duplicate path + +**Cause:** Two environments are publishing to the same API path. This happens when `apiPathPrefix` is omitted or set to the same value for multiple environments. + +**Fix:** Ensure each environment has a unique `apiPathPrefix`. If you intentionally share a path across environments (only one env active at a time), omit `apiPathPrefix` for that resource and manage path assignment manually via a per-API `path` override. + +### `--delete-unmatched` removed resources from another environment + +**Cause:** The environment whose resources were deleted did not have `namePrefix` or `nameSuffix` set, so its resources had no namespace prefix and were indistinguishable from unmanaged resources. + +**Fix:** Every environment on a shared APIM **must** have a unique `namePrefix` or `nameSuffix`. Add the missing `environment:` block to the override file for the affected environment, republish it to restore the deleted resources, and ensure all subsequent publishes include the `environment:` block. + +### I have a shared APIM for dev/qa but a dedicated APIM for prod + +No special configuration is needed — the two patterns are independent and can be mixed across override files. + +```yaml +# configuration.dev.yaml — shared APIM, env prefix required +environment: + namePrefix: "dev-" + apiPathPrefix: "dev/" +``` + +```yaml +# configuration.prod.yaml — dedicated APIM, no environment block needed +# No environment: block +apis: + - name: petstore-api + properties: + serviceUrl: https://petstore.contoso.com +``` + +Publish each with its respective `--service-name` (shared APIM for dev/qa, dedicated APIM for prod). The `environment:` block is only interpreted during the publish where it appears. + +## Limitations + +- **Policy XML rewriting is regex-based.** The rewriter uses targeted regular expressions for `{{token}}`, `fragment-id="..."`, and `backend-id="..."` patterns. Tokens or attributes inside CDATA sections or XML comments may not be rewritten correctly. Avoid placing canonical resource name references inside CDATA blocks or comments in policy XML. +- **Workspace container is not affixed.** The Workspace resource name itself is never prefixed/suffixed, even if `Workspace` is in `appliesTo`. Resources *inside* a workspace are affixed normally via their parent workspace's name. +- **`ServicePolicy` and singleton types are never affixed.** The service-level policy, API policies, product policies, and operation policies derive their identity from their parent resource and are handled transitively — see [Types that are never affixed](#types-that-are-never-affixed). +- **No `--strip-prefix` for extraction.** There is no built-in flag to strip environment prefixes when extracting from a shared APIM. Always extract from a dedicated source-of-truth APIM with canonical names. diff --git a/package-lock.json b/package-lock.json index 2fa603f3..73ca7534 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "@typescript-eslint/eslint-plugin": "^8.58.1", "@typescript-eslint/parser": "^8.58.1", "@vitest/coverage-v8": "^4.1.4", + "ajv": "^8.20.0", "eslint": "^10.2.0", "tsx": "^4.21.0", "typescript": "^6.0.2", @@ -1642,14 +1643,16 @@ } }, "node_modules/ajv": { - "version": "6.15.0", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -1965,6 +1968,23 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/eslint/node_modules/eslint-visitor-keys": { "version": "5.0.1", "dev": true, @@ -1984,6 +2004,13 @@ "node": ">= 4" } }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/espree": { "version": "11.2.0", "dev": true, @@ -2072,6 +2099,8 @@ }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "dev": true, "license": "MIT" }, @@ -2080,6 +2109,23 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fdir": { "version": "6.5.0", "dev": true, @@ -2355,7 +2401,9 @@ "license": "MIT" }, "node_modules/json-schema-traverse": { - "version": "0.4.1", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, "license": "MIT" }, @@ -2941,12 +2989,24 @@ }, "node_modules/punycode": { "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rolldown": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", @@ -3211,6 +3271,8 @@ }, "node_modules/uri-js": { "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { diff --git a/package.json b/package.json index f385abb6..eda5fc41 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "@typescript-eslint/eslint-plugin": "^8.58.1", "@typescript-eslint/parser": "^8.58.1", "@vitest/coverage-v8": "^4.1.4", + "ajv": "^8.20.0", "eslint": "^10.2.0", "tsx": "^4.21.0", "typescript": "^6.0.2", diff --git a/schemas/v1/override-config.schema.json b/schemas/v1/override-config.schema.json index 51197471..f46947eb 100644 --- a/schemas/v1/override-config.schema.json +++ b/schemas/v1/override-config.schema.json @@ -66,6 +66,10 @@ "workspaces": { "$ref": "#/definitions/overrideSection", "description": "Workspaces overrides. Values may include {#[TOKEN_NAME]#} placeholders for CI/CD secret token substitution." + }, + "environment": { + "$ref": "#/definitions/environmentOverride", + "description": "Per-environment settings for publishing multiple environments (dev/qa/prod) to a shared APIM instance. When set, resource names are prefixed/suffixed at publish time. Omit for standard single-APIM-per-env deployments." } }, "definitions": { @@ -702,6 +706,53 @@ } }, "additionalProperties": true + }, + "environmentOverride": { + "type": "object", + "additionalProperties": false, + "properties": { + "namePrefix": { + "type": "string", + "description": "Prefix applied to resource names for types in appliesTo (e.g. \"dev-\"). Must contain only characters valid in APIM resource names.", + "pattern": "^[A-Za-z0-9-]*$" + }, + "nameSuffix": { + "type": "string", + "description": "Suffix applied to resource names for types in appliesTo (e.g. \"-dev\").", + "pattern": "^[A-Za-z0-9-]*$" + }, + "appliesTo": { + "type": "array", + "description": "Resource type names to affix. If omitted, a default set is applied (Api, Product, NamedValue, Backend, Logger, PolicyFragment, VersionSet, Tag, Group, Subscription, Workspace).", + "items": { + "type": "string", + "enum": [ + "Api", + "Product", + "NamedValue", + "Backend", + "Logger", + "PolicyFragment", + "VersionSet", + "Tag", + "Group", + "Subscription", + "Workspace", + "Gateway", + "Diagnostic", + "GlobalSchema", + "PolicyRestriction", + "Documentation" + ] + }, + "uniqueItems": true + }, + "apiPathPrefix": { + "type": "string", + "description": "String prepended to Api properties.path (e.g. \"dev/\"). Not applied when the same API has an explicit path override.", + "pattern": "^[A-Za-z0-9/_-]*$" + } + } } } } diff --git a/scripts/generate-schemas.mjs b/scripts/generate-schemas.mjs index 66760298..524a563f 100644 --- a/scripts/generate-schemas.mjs +++ b/scripts/generate-schemas.mjs @@ -281,6 +281,12 @@ function buildOverrideProperties() { $ref: '#/definitions/policyOverrideSection', description: `Service-level policies overrides. ${tokenNote}`, }; + } else if (field === 'environment') { + props.environment = { + $ref: '#/definitions/environmentOverride', + description: + 'Per-environment settings for publishing multiple environments (dev/qa/prod) to a shared APIM instance. When set, resource names are prefixed/suffixed at publish time. Omit for standard single-APIM-per-env deployments.', + }; } else { props[field] = { $ref: '#/definitions/overrideSection', @@ -647,6 +653,41 @@ const overrideSchema = { }, additionalProperties: true, }, + + environmentOverride: { + type: 'object', + additionalProperties: false, + properties: { + namePrefix: { + type: 'string', + description: 'Prefix applied to resource names for types in appliesTo (e.g. "dev-"). Must contain only characters valid in APIM resource names.', + pattern: '^[A-Za-z0-9-]*$', + }, + nameSuffix: { + type: 'string', + description: 'Suffix applied to resource names for types in appliesTo (e.g. "-dev").', + pattern: '^[A-Za-z0-9-]*$', + }, + appliesTo: { + type: 'array', + description: 'Resource type names to affix. If omitted, a default set is applied (Api, Product, NamedValue, Backend, Logger, PolicyFragment, VersionSet, Tag, Group, Subscription, Workspace).', + items: { + type: 'string', + enum: [ + 'Api', 'Product', 'NamedValue', 'Backend', 'Logger', 'PolicyFragment', + 'VersionSet', 'Tag', 'Group', 'Subscription', 'Workspace', + 'Gateway', 'Diagnostic', 'GlobalSchema', 'PolicyRestriction', 'Documentation', + ], + }, + uniqueItems: true, + }, + apiPathPrefix: { + type: 'string', + description: 'String prepended to Api properties.path (e.g. "dev/"). Not applied when the same API has an explicit path override.', + pattern: '^[A-Za-z0-9/_-]*$', + }, + }, + }, }, }; diff --git a/src/lib/config-loader.ts b/src/lib/config-loader.ts index 8130bd63..74669367 100644 --- a/src/lib/config-loader.ts +++ b/src/lib/config-loader.ts @@ -7,7 +7,7 @@ import * as fs from 'node:fs/promises'; import * as yaml from 'js-yaml'; -import { FilterConfig, OverrideConfig, OverrideSection, OverrideEntry, ApiSubFilter, WorkspaceSubFilter } from '../models/config.js'; +import { FilterConfig, OverrideConfig, OverrideSection, OverrideEntry, ApiSubFilter, WorkspaceSubFilter, EnvironmentOverride } from '../models/config.js'; import { logger } from './logger.js'; @@ -292,8 +292,12 @@ function normalizeOverrideConfig(parsed: Record): OverrideConfi } } + if (parsed.environment !== undefined) { + normalized.environment = parseEnvironmentOverride(parsed.environment); + } + // Warn about unknown top-level keys - const knownKeys = new Set([...ALL_OVERRIDE_SECTIONS, 'apimServiceName']); + const knownKeys = new Set([...ALL_OVERRIDE_SECTIONS, 'apimServiceName', 'environment']); for (const key of Object.keys(parsed)) { if (!knownKeys.has(key)) { logger.warn(`Unknown override config key '${key}'; ignoring.`); @@ -303,6 +307,53 @@ function normalizeOverrideConfig(parsed: Record): OverrideConfi return normalized; } +/** + * Parse and validate the environment override object. + */ +function parseEnvironmentOverride(raw: unknown): EnvironmentOverride { + if (!isPlainObject(raw)) { + throw new Error( + `Invalid override config: 'environment' must be an object, got ${typeof raw}.` + ); + } + + const result: EnvironmentOverride = {}; + + if (raw.namePrefix !== undefined) { + if (typeof raw.namePrefix !== 'string') { + throw new Error(`Invalid override config: 'environment.namePrefix' must be a string.`); + } + result.namePrefix = raw.namePrefix; + } + + if (raw.nameSuffix !== undefined) { + if (typeof raw.nameSuffix !== 'string') { + throw new Error(`Invalid override config: 'environment.nameSuffix' must be a string.`); + } + result.nameSuffix = raw.nameSuffix; + } + + if (raw.apiPathPrefix !== undefined) { + if (typeof raw.apiPathPrefix !== 'string') { + throw new Error(`Invalid override config: 'environment.apiPathPrefix' must be a string.`); + } + result.apiPathPrefix = raw.apiPathPrefix; + } + + if (raw.appliesTo !== undefined) { + result.appliesTo = assertStringArray(raw.appliesTo, 'environment.appliesTo'); + } + + const knownEnvKeys = new Set(['namePrefix', 'nameSuffix', 'appliesTo', 'apiPathPrefix']); + for (const key of Object.keys(raw)) { + if (!knownEnvKeys.has(key)) { + logger.warn(`Unknown environment override key '${key}'; ignoring.`); + } + } + + return result; +} + /** * Known child section keys for each parent override type. * Used to distinguish nested sub-resource overrides from regular properties. diff --git a/src/models/config.ts b/src/models/config.ts index d16f9243..e715e65a 100644 --- a/src/models/config.ts +++ b/src/models/config.ts @@ -7,6 +7,7 @@ import { ApimServiceContext } from './types.js'; import { LogLevel } from '../lib/logger.js'; +import type { EnvMapping } from '../services/env-mapper.js'; export interface ExtractConfig { service: ApimServiceContext; @@ -72,10 +73,35 @@ export interface FilterConfig { workspaceSubFilters?: Record; } +/** + * Sets of canonical artifact names used to gate policy XML reference rewriting. + * Populated once per publish from the full artifact descriptor list. + */ +export interface KnownArtifactSets { + /** Canonical NamedValue resource names */ + namedValues: ReadonlySet; + /** Canonical PolicyFragment resource names */ + fragments: ReadonlySet; + /** Canonical Backend resource names */ + backends: ReadonlySet; +} + export interface PublishConfig { service: ApimServiceContext; sourceDir: string; overrides?: OverrideConfig; + /** + * Pre-built environment name mapping (prefix/suffix + appliesTo). + * Constructed by publish-service from overrides.environment. + * When present, delete-unmatched scopes deletions to this env's namespace only. + * Absent → original behaviour (no namespace scoping, 100 % back-compat). + */ + envMapping?: EnvMapping; + /** + * Known canonical artifact name sets for policy XML ref rewriting. + * Built by publish-service after determinePublishTargets. + */ + knownArtifactSets?: KnownArtifactSets; dryRun: boolean; deleteUnmatched: boolean; commitId?: string; @@ -95,6 +121,26 @@ export interface OverrideEntry { /** A section of overrides: resource name → override entry */ export type OverrideSection = Record; +/** + * Per-environment settings for publishing to a shared APIM instance. + * When present, resource names are prefixed/suffixed at publish time so + * multiple environments (dev/qa/prod) can coexist on one APIM. + * Absent = current behaviour (no affix, 100% back-compat). + */ +export interface EnvironmentOverride { + /** Prefix applied to resource names for types in appliesTo. Optional. */ + namePrefix?: string; + /** Suffix applied to resource names for types in appliesTo. Optional. */ + nameSuffix?: string; + /** + * Resource type names that get the affix. If omitted, a default set is used. + * Values are the string names of the ResourceType enum (e.g. "Api", "Product"). + */ + appliesTo?: string[]; + /** Prefix prepended to Api properties.path (e.g. "dev/"). Not applied when a per-API path override exists. */ + apiPathPrefix?: string; +} + /** * Environment-specific override configuration. * Supports all Toolkit override sections with generic property passthrough. @@ -115,6 +161,7 @@ export interface OverrideConfig { tags?: OverrideSection; policyFragments?: OverrideSection; workspaces?: OverrideSection; + environment?: EnvironmentOverride; } export interface InitConfig { diff --git a/src/services/delete-unmatched-service.ts b/src/services/delete-unmatched-service.ts index 7980a871..1e1b44fa 100644 --- a/src/services/delete-unmatched-service.ts +++ b/src/services/delete-unmatched-service.ts @@ -5,6 +5,22 @@ * List current APIM resources, diff against artifact descriptors, * generate DELETE actions in reverse dependency order. * Requires --delete-unmatched flag per FR-017. + * + * ## Env-namespace scoping + * + * When `config.envMapping` is set (multi-env shared-APIM mode), each deployed + * resource is tested against the current env's namespace before being considered + * for deletion. Resources whose names do NOT carry the env prefix/suffix are + * silently skipped — they belong to another environment running on the same APIM. + * Deployed names are then converted to canonical form for comparison against the + * local artifact set (which always stores canonical names). + * + * ### Known caveat — "prefix dropped mid-life" + * If an env mapping was previously active (resources were published as `dev-foo`) + * and the config is later changed to have no prefix (or a different prefix), the + * old affixed resources will no longer match the namespace and will be silently + * skipped rather than deleted. The user must manually clean up orphaned resources + * in that scenario. */ import type { IApimClient } from '../clients/iapim-client.js'; @@ -14,6 +30,8 @@ import type { PublishConfig } from '../models/config.js'; import { ResourceType } from '../models/resource-types.js'; import { getTopologicalOrder } from '../lib/dependency-graph.js'; import { getNameFromNameParts } from '../lib/resource-path.js'; +import { logger } from '../lib/logger.js'; +import { toCanonicalDescriptor } from './env-mapper.js'; /** * Built-in groups that should never be deleted @@ -34,6 +52,12 @@ const SYSTEM_RESOURCES = new Set([ * List APIM resources not in local artifacts. * Returns descriptors to DELETE in reverse dependency order. * Used when --delete-unmatched flag is set. + * + * When `config.envMapping` is present the function operates in namespace-scoped + * mode: only resources that belong to the current env's namespace are considered + * for deletion. Resources outside the namespace (other envs on the same APIM) + * are skipped. BUILT_IN_GROUPS and SYSTEM_RESOURCES are always preserved, + * checked against the **canonical** (un-affixed) name. */ export async function computeDeleteActions( client: IApimClient, @@ -41,10 +65,12 @@ export async function computeDeleteActions( context: ApimServiceContext, config: PublishConfig ): Promise { - // List all resources from local artifacts + // List all resources from local artifacts (always in canonical / un-affixed form) const localDescriptors = await store.listResources(config.sourceDir); const localSet = createResourceSet(localDescriptors); + const { envMapping } = config; + const deleteDescriptors: ResourceDescriptor[] = []; // Get topological order and reverse it for deletion @@ -58,22 +84,47 @@ export async function computeDeleteActions( const apimResources = client.listResources(context, resourceType); for await (const resource of apimResources) { - const descriptor = parseResourceDescriptor(resource, resourceType); - - if (!descriptor) { - continue; - } + const deployedDescriptor = parseResourceDescriptor(resource, resourceType); - // Skip system resources - if (isSystemResource(descriptor)) { + if (!deployedDescriptor) { continue; } - // Check if resource exists in local artifacts - const resourceKey = getResourceKey(descriptor); - if (!localSet.has(resourceKey)) { - // Resource exists in APIM but not in local artifacts - mark for deletion - deleteDescriptors.push(descriptor); + if (envMapping !== undefined) { + // ── Namespace-scoped mode ──────────────────────────────────────────── + // Convert deployed → canonical; null means outside this env's namespace. + const canonicalDescriptor = toCanonicalDescriptor(deployedDescriptor, envMapping); + + if (canonicalDescriptor === null) { + // Resource belongs to another environment — do NOT delete. + const skipName = deployedDescriptor.nameParts[0] ?? resourceType; + logger.debug( + `[delete-unmatched] Skipping ${resourceType}/${skipName}: outside env namespace` + ); + continue; + } + + // System-resource check uses the canonical (un-affixed) name. + if (isSystemResource(canonicalDescriptor)) { + continue; + } + + // Compare canonical key against the local artifact set. + if (!localSet.has(getResourceKey(canonicalDescriptor))) { + // Not in local artifacts → mark the **deployed** descriptor for deletion + // so client.deleteResource receives the actual APIM resource name. + deleteDescriptors.push(deployedDescriptor); + } + } else { + // ── Original behaviour (no env mapping) ───────────────────────────── + if (isSystemResource(deployedDescriptor)) { + continue; + } + + const resourceKey = getResourceKey(deployedDescriptor); + if (!localSet.has(resourceKey)) { + deleteDescriptors.push(deployedDescriptor); + } } } } catch { @@ -104,12 +155,28 @@ function getResourceKey(descriptor: ResourceDescriptor): string { } /** - * Parse resource descriptor from APIM resource JSON + * Parse resource descriptor from APIM resource JSON. + * + * Supports an optional `nameParts` string-array property on the resource object + * for structured multi-segment descriptors (used by tests and future structured + * APIM client responses). Falls back to extracting from `name` or `id`. */ function parseResourceDescriptor( resource: Record, resourceType: ResourceType ): ResourceDescriptor | null { + // If the resource carries pre-parsed nameParts, use them directly. + if ( + Array.isArray(resource.nameParts) && + resource.nameParts.every((p: unknown) => typeof p === 'string') + ) { + // Safe after the .every() guard; filter preserves order and produces string[]. + const nameParts = resource.nameParts.filter((p): p is string => typeof p === 'string'); + const workspace = + typeof resource.workspace === 'string' ? resource.workspace : undefined; + return { type: resourceType, nameParts, ...(workspace !== undefined ? { workspace } : {}) }; + } + // Extract name from resource const name = extractResourceName(resource); if (!name) { @@ -122,10 +189,6 @@ function parseResourceDescriptor( nameParts: [name], }; - // Extract parent/grandparent from resource properties if needed - // This depends on the resource structure from APIM - // For now, we'll use a simple heuristic based on the resource type - return descriptor; } @@ -148,7 +211,9 @@ function extractResourceName(resource: Record): string | null { } /** - * Check if a resource is a system resource that should not be deleted + * Check if a resource is a system resource that should not be deleted. + * The descriptor's nameParts should be in canonical (un-affixed) form when + * called under env-namespace mode. */ function isSystemResource(descriptor: ResourceDescriptor): boolean { if (descriptor.nameParts.length === 0) return false; @@ -180,3 +245,5 @@ function isSystemResource(descriptor: ResourceDescriptor): boolean { return false; } + + diff --git a/src/services/env-mapper.ts b/src/services/env-mapper.ts new file mode 100644 index 00000000..1f6bbe25 --- /dev/null +++ b/src/services/env-mapper.ts @@ -0,0 +1,224 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { ResourceType } from '../models/resource-types.js'; +import type { ResourceDescriptor } from '../models/types.js'; +import type { EnvironmentOverride, OverrideConfig } from '../models/config.js'; + +// Re-export for callers that previously imported EnvironmentOverride from here. +export type { EnvironmentOverride } from '../models/config.js'; + +export interface EnvMapping { + prefix: string; + suffix: string; + appliesTo: ReadonlySet; + apiPathPrefix?: string; +} + +/** Default resource types affixed when appliesTo is omitted. */ +export const DEFAULT_APPLIES_TO: ReadonlySet = new Set([ + ResourceType.Api, + ResourceType.Product, + ResourceType.NamedValue, + ResourceType.Backend, + ResourceType.Logger, + ResourceType.PolicyFragment, + ResourceType.VersionSet, + ResourceType.Tag, + ResourceType.Group, + ResourceType.Subscription, + ResourceType.Workspace, +]); + +/** + * Types that cannot be affixed directly (singletons, association children, wikis). + * These are handled via their parent's name — not via a direct appliesTo entry. + */ +export const NON_AFFIXABLE_TYPES: ReadonlySet = new Set([ + // Singletons with fixed/derived names + ResourceType.ServicePolicy, + ResourceType.ApiPolicy, + ResourceType.ProductPolicy, + ResourceType.ApiOperationPolicy, + ResourceType.GraphQLResolverPolicy, + ResourceType.ApiWiki, + ResourceType.ProductWiki, + ResourceType.McpServer, + // Association / child types — affixed via their parent + ResourceType.ProductApi, + ResourceType.ProductGroup, + ResourceType.ProductTag, + ResourceType.GatewayApi, + ResourceType.ApiTag, + ResourceType.ApiDiagnostic, + ResourceType.ApiOperation, + ResourceType.ApiSchema, + ResourceType.ApiRelease, + ResourceType.ApiTagDescription, + ResourceType.GraphQLResolver, +]); + +/** + * Per-type mapping of each namePart index to the ResourceType it represents. + * `null` means the segment is a positional sub-resource key that is NOT independently affixable. + * Types absent from this map are top-level: nameParts[0] represents the type itself. + */ +const SEGMENT_TYPES: ReadonlyMap> = new Map([ + // No nameParts + [ResourceType.ServicePolicy, []], + + // Singleton children: nameParts[0] is the parent resource name + [ResourceType.ApiPolicy, [ResourceType.Api]], + [ResourceType.ApiWiki, [ResourceType.Api]], + [ResourceType.McpServer, [ResourceType.Api]], + [ResourceType.ProductPolicy, [ResourceType.Product]], + [ResourceType.ProductWiki, [ResourceType.Product]], + + // Association types: each segment is an independently-named resource + [ResourceType.ProductApi, [ResourceType.Product, ResourceType.Api]], + [ResourceType.ProductGroup, [ResourceType.Product, ResourceType.Group]], + [ResourceType.ProductTag, [ResourceType.Product, ResourceType.Tag]], + [ResourceType.GatewayApi, [ResourceType.Gateway, ResourceType.Api]], + [ResourceType.ApiTag, [ResourceType.Api, ResourceType.Tag]], + [ResourceType.ApiDiagnostic, [ResourceType.Api, ResourceType.Diagnostic]], + + // Sub-resource children: nameParts[0] = parent Api, nameParts[1] = scoped sub-resource key + [ResourceType.ApiOperation, [ResourceType.Api, null]], + [ResourceType.ApiOperationPolicy, [ResourceType.Api, null]], + [ResourceType.ApiSchema, [ResourceType.Api, null]], + [ResourceType.ApiRelease, [ResourceType.Api, null]], + [ResourceType.ApiTagDescription, [ResourceType.Api, null]], + [ResourceType.GraphQLResolver, [ResourceType.Api, null]], + [ResourceType.GraphQLResolverPolicy, [ResourceType.Api, null]], +]); + +/** Return undefined when no environment block is present or when prefix+suffix+apiPathPrefix are all empty and appliesTo is not set. */ +export function buildEnvMapping(env: EnvironmentOverride | undefined): EnvMapping | undefined { + if (env === undefined) return undefined; + + const prefix = env.namePrefix?.trim() ?? ''; + const suffix = env.nameSuffix?.trim() ?? ''; + const apiPathPrefix = env.apiPathPrefix?.trim() || undefined; + + if (prefix === '' && suffix === '' && apiPathPrefix === undefined && env.appliesTo === undefined) { + return undefined; + } + + // Convert string[] → ReadonlySet, filtering out anything that isn't + // a valid ResourceType. The validator surfaces a hard error for unknown types before + // reaching this code, so in practice env.appliesTo is either undefined or all-valid. + // The filter is a defensive fallback that keeps the internal set type-safe. + const validTypes = new Set(Object.values(ResourceType) as string[]); + const appliesTo: ReadonlySet = + env.appliesTo !== undefined + ? new Set(env.appliesTo.filter((t) => validTypes.has(t)) as ResourceType[]) + : DEFAULT_APPLIES_TO; + + return apiPathPrefix !== undefined + ? { prefix, suffix, appliesTo, apiPathPrefix } + : { prefix, suffix, appliesTo }; +} + +/** Convenience: extract environment block from OverrideConfig then buildEnvMapping. */ +export function buildEnvMappingFromOverrides(overrides: OverrideConfig | undefined): EnvMapping | undefined { + if (overrides === undefined) return undefined; + return buildEnvMapping(overrides.environment); +} + +/** + * Convert a deployed descriptor to its canonical (un-affixed) form. + * + * Returns `null` when the descriptor is outside this env's namespace + * (i.e. belongs to another environment — do NOT delete it). + * + * - For top-level types: checks and strips the affix on `nameParts[0]`. + * - For child/association types: checks `nameParts[0]` (the parent segment) + * against the parent's ResourceType. All segments are converted per their + * SEGMENT_TYPES entry; positional sub-resource keys (null) are kept as-is. + * - For ServicePolicy (empty nameParts): returned unchanged (no affix, no namespace check). + * - When type ∉ appliesTo: namespace scoping does not apply → returned unchanged. + */ +export function toCanonicalDescriptor(d: ResourceDescriptor, m: EnvMapping): ResourceDescriptor | null { + const segTypes = SEGMENT_TYPES.get(d.type); + + if (segTypes !== undefined) { + // Child / association type + if (d.nameParts.length === 0) return d; // ServicePolicy (no nameParts) + + // nameParts[0] is the parent segment — it determines namespace membership + const parentSegType = segTypes.length > 0 ? segTypes[0] : null; + if (parentSegType !== null && !isInEnvNamespace(d.nameParts[0], parentSegType, m)) { + return null; // parent belongs to another env + } + + const newParts = d.nameParts.map((part, i) => { + const segType = i < segTypes.length ? segTypes[i] : null; + if (segType === null || segType === undefined) return part; // positional sub-resource key + return toCanonicalName(part, segType, m) ?? part; // defensive fallback + }); + return { ...d, nameParts: newParts }; + } + + // Top-level type + if (d.nameParts.length === 0) return d; + if (!isInEnvNamespace(d.nameParts[0], d.type, m)) return null; + const canonicalFirst = toCanonicalName(d.nameParts[0], d.type, m); + if (canonicalFirst === undefined) return null; // defensive + return { ...d, nameParts: [canonicalFirst, ...d.nameParts.slice(1)] }; +} + +export function toDeployedName(name: string, type: ResourceType, m: EnvMapping): string { + if (!m.appliesTo.has(type)) return name; + return `${m.prefix}${name}${m.suffix}`; +} + +/** + * deployed → canonical name. + * Returns undefined if deployedName does not belong to this env's namespace when type ∈ appliesTo. + * Returns input unchanged when type ∉ appliesTo. + */ +export function toCanonicalName(deployedName: string, type: ResourceType, m: EnvMapping): string | undefined { + if (!m.appliesTo.has(type)) return deployedName; + if (!isInEnvNamespace(deployedName, type, m)) return undefined; + + let name = deployedName; + if (m.prefix) name = name.slice(m.prefix.length); + if (m.suffix) name = name.slice(0, name.length - m.suffix.length); + return name; +} + +/** + * true iff deployedName has the env's prefix+suffix for this type. + * When type ∉ appliesTo → returns true (namespace scoping doesn't apply to this type). + */ +export function isInEnvNamespace(deployedName: string, type: ResourceType, m: EnvMapping): boolean { + if (!m.appliesTo.has(type)) return true; + if (deployedName.length < m.prefix.length + m.suffix.length) return false; + return deployedName.startsWith(m.prefix) && deployedName.endsWith(m.suffix); +} + +/** + * Rewrite a descriptor by affixing name segments based on the segment's associated ResourceType. + * For top-level types: nameParts[0] is affixed if type ∈ appliesTo. + * For singleton children (ApiPolicy, ProductPolicy, etc.): nameParts[0] (parent name) is affixed if parent type ∈ appliesTo. + * For association types (ProductApi, ApiTag, etc.): each segment is affixed if its type ∈ appliesTo. + * For sub-resource children (ApiOperation, ApiSchema, etc.): only nameParts[0] (parent) is affixed; sub-resource keys are unchanged. + * The workspace field is NOT affixed (workspace container rename is handled separately). + */ +export function mapDescriptor(d: ResourceDescriptor, m: EnvMapping): ResourceDescriptor { + const segTypes = SEGMENT_TYPES.get(d.type); + + if (segTypes !== undefined) { + const newParts = d.nameParts.map((part, i) => { + const segType = i < segTypes.length ? segTypes[i] : null; + if (segType === null || segType === undefined) return part; + return toDeployedName(part, segType, m); + }); + return { ...d, nameParts: newParts }; + } + + // Top-level type: affix nameParts[0] if this type ∈ appliesTo + if (d.nameParts.length === 0) return d; + const [first, ...rest] = d.nameParts; + return { ...d, nameParts: [toDeployedName(first, d.type, m), ...rest] }; +} diff --git a/src/services/env-mapping-validator.ts b/src/services/env-mapping-validator.ts new file mode 100644 index 00000000..a5fb1580 --- /dev/null +++ b/src/services/env-mapping-validator.ts @@ -0,0 +1,181 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { ResourceType } from '../models/resource-types.js'; +import type { ResourceDescriptor } from '../models/types.js'; +import type { OverrideConfig, PublishConfig } from '../models/config.js'; +import { + buildEnvMappingFromOverrides, + NON_AFFIXABLE_TYPES, + type EnvMapping, +} from './env-mapper.js'; +import { logger } from '../lib/logger.js'; + +/** Only alphanumeric and hyphens are safe in APIM resource names. */ +const VALID_AFFIX_RE = /^[A-Za-z0-9-]*$/; +const VALID_PATH_PREFIX_RE = /^[A-Za-z0-9/_-]*$/; + +/** Maps OverrideConfig section keys (that carry named entries) to their ResourceType. */ +const OVERRIDE_SECTION_TYPES = new Map([ + ['apis', ResourceType.Api], + ['backends', ResourceType.Backend], + ['diagnostics', ResourceType.Diagnostic], + ['gateways', ResourceType.Gateway], + ['groups', ResourceType.Group], + ['loggers', ResourceType.Logger], + ['namedValues', ResourceType.NamedValue], + ['policyFragments', ResourceType.PolicyFragment], + ['products', ResourceType.Product], + ['subscriptions', ResourceType.Subscription], + ['tags', ResourceType.Tag], + ['versionSets', ResourceType.VersionSet], + ['workspaces', ResourceType.Workspace], +]); + +/** + * Validate the environment block in overrides, build an EnvMapping, and store it on config. + * Throws on mis-configuration; warns on likely-but-not-fatal issues. + * Exported for isolated unit testing. + */ +export function validateAndBuildEnvMapping( + overrides: OverrideConfig | undefined, + artifactDescriptors: ResourceDescriptor[], + config: PublishConfig +): void { + if (overrides?.environment === undefined) return; + + const env = overrides.environment; + + // --- Require at least one name affix ------------------------------------ + // A path-only environment block is unsafe: canonical resource names would + // collide across environments on a shared APIM instance, and + // `apiops publish --delete-unmatched` would treat every other environment's + // resources as stale and delete them. Block this misconfiguration up front + // with a clear message rather than let the user trash a shared instance. + const prefix = env.namePrefix?.trim() ?? ''; + const suffix = env.nameSuffix?.trim() ?? ''; + if (prefix === '' && suffix === '') { + throw new Error( + `[publish] The 'environment' block requires at least one of 'namePrefix' or 'nameSuffix' (both may be set). ` + + `Path-only isolation via 'apiPathPrefix' alone is not supported because resource names would collide ` + + `across environments on a shared APIM instance, and 'publish --delete-unmatched' would delete resources ` + + `belonging to other environments. Either add a name affix, or remove the 'environment' block entirely.` + ); + } + + // --- Validate appliesTo entries ---------------------------------------- + if (env.appliesTo !== undefined) { + const validTypes = new Set(Object.values(ResourceType)); + const unknownTypes: string[] = []; + const nonAffixableFound: string[] = []; + + for (const typeName of env.appliesTo) { + if (!validTypes.has(typeName as ResourceType)) { + unknownTypes.push(typeName); + } else if (NON_AFFIXABLE_TYPES.has(typeName as ResourceType)) { + nonAffixableFound.push(typeName); + } + } + + if (unknownTypes.length > 0) { + throw new Error( + `[publish] environment.appliesTo contains unknown resource type(s): ${unknownTypes.join(', ')}. ` + + `Valid types are: ${[...validTypes].join(', ')}` + ); + } + + if (nonAffixableFound.length > 0) { + throw new Error( + `[publish] environment.appliesTo contains non-affixable type(s): ${nonAffixableFound.join(', ')}. ` + + `These are singleton or child resources whose names are fixed or derived from their parent. ` + + `Remove them from appliesTo.` + ); + } + } + + // Build the mapping (may still be undefined if all values are blank/unset) + const mapping: EnvMapping | undefined = buildEnvMappingFromOverrides(overrides); + if (mapping === undefined) return; + + // --- Runtime affix character check. + // The JSON schema in schemas/v1/override-config.schema.json is for editor + // tooling only; there is no runtime schema enforcement. This is the sole + // runtime guard against unsafe affix characters, so keep it here. + if (env.namePrefix !== undefined && !VALID_AFFIX_RE.test(env.namePrefix)) { + logger.warn( + `[publish] environment.namePrefix "${env.namePrefix}" contains characters that may be invalid ` + + `in APIM resource names. Only [A-Za-z0-9-] are safe.` + ); + } + if (env.nameSuffix !== undefined && !VALID_AFFIX_RE.test(env.nameSuffix)) { + logger.warn( + `[publish] environment.nameSuffix "${env.nameSuffix}" contains characters that may be invalid ` + + `in APIM resource names. Only [A-Za-z0-9-] are safe.` + ); + } + if (env.apiPathPrefix !== undefined && !VALID_PATH_PREFIX_RE.test(env.apiPathPrefix)) { + logger.warn( + `[publish] environment.apiPathPrefix "${env.apiPathPrefix}" contains characters that may be invalid ` + + `in APIM API paths. Only [A-Za-z0-9/_-] are safe.` + ); + } + + // --- Warn if apiPathPrefix is set but no Api descriptors are present --- + if (env.apiPathPrefix !== undefined) { + const hasApiDescriptors = artifactDescriptors.some((d) => d.type === ResourceType.Api); + if (!hasApiDescriptors) { + logger.warn( + `[publish] environment.apiPathPrefix is set but no Api resources were found in the artifact descriptors. ` + + `This may be a typo or misconfiguration.` + ); + } + } + + // --- Warn if any affixed name would exceed APIM's 80-character limit --- + // Top-level APIM resource names (APIs, NamedValues, Products, Backends, etc.) + // are capped at 80 characters. If prefix + canonical + suffix crosses that + // threshold, the ARM PUT will fail mid-publish with a cryptic 400 that does + // not name the affix as the cause. Surface it up front instead. + for (const d of artifactDescriptors) { + if (d.nameParts.length === 0) continue; + if (!mapping.appliesTo.has(d.type)) continue; + const canonical = d.nameParts[0]; + if (canonical === undefined) continue; + const deployedLen = mapping.prefix.length + canonical.length + mapping.suffix.length; + if (deployedLen > 80) { + logger.warn( + `[publish] Affixed name "${mapping.prefix}${canonical}${mapping.suffix}" would be ${deployedLen} characters ` + + `for ${d.type}, exceeding APIM's 80-character resource name limit. Shorten the canonical name or the affix ` + + `before publishing.` + ); + } + } + + // --- Warn for override entries that don't match any artifact descriptor of that type --- + for (const [sectionKey, resourceType] of OVERRIDE_SECTION_TYPES) { + const section = overrides[sectionKey]; + if (section === undefined) continue; + + const knownNames = new Set( + artifactDescriptors + .filter((d) => d.type === resourceType && d.nameParts.length > 0) + .map((d) => d.nameParts[0] ?? '') + ); + + for (const overrideName of Object.keys(section)) { + if (!knownNames.has(overrideName)) { + logger.warn( + `[publish] Override entry "${overrideName}" in overrides.${sectionKey} does not match any ` + + `${resourceType} artifact descriptor. This may be a stale override after a rename.` + ); + } + } + } + + // --- Log active mapping summary ---------------------------------------- + logger.info( + `[publish] Environment affix active: prefix="${mapping.prefix}" suffix="${mapping.suffix}" applies to ${mapping.appliesTo.size} types` + ); + + config.envMapping = mapping; +} diff --git a/src/services/override-merger.ts b/src/services/override-merger.ts index 5bc4e25e..e6d3a012 100644 --- a/src/services/override-merger.ts +++ b/src/services/override-merger.ts @@ -13,10 +13,13 @@ import { OverrideConfig, OverrideSection, OverrideEntry } from '../models/config import { logger } from '../lib/logger.js'; import { getNameFromNameParts, isSingletonType } from '../lib/resource-path.js'; +/** Keys of OverrideConfig that hold OverrideSection values (excludes non-section fields). */ +type OverrideSectionKey = keyof { [K in keyof OverrideConfig as OverrideConfig[K] extends OverrideSection | undefined ? K : never]: unknown }; + /** * Map resource types to their top-level override config section key. */ -const OVERRIDE_SECTION_MAP: Partial> = { +const OVERRIDE_SECTION_MAP: Partial> = { [ResourceType.NamedValue]: 'namedValues', [ResourceType.Backend]: 'backends', [ResourceType.Api]: 'apis', @@ -38,7 +41,7 @@ const OVERRIDE_SECTION_MAP: Partial> * Used for nested override lookup (e.g., ApiDiagnostic → apis.children.diagnostics). * `namePartIndex` indicates which name part identifies the child (default: 1). */ -const CHILD_OVERRIDE_MAP: Partial> = { +const CHILD_OVERRIDE_MAP: Partial> = { [ResourceType.ApiDiagnostic]: { parentSection: 'apis', childKey: 'diagnostics' }, [ResourceType.ApiOperation]: { parentSection: 'apis', childKey: 'operations' }, [ResourceType.ApiPolicy]: { parentSection: 'apis', childKey: 'policies' }, @@ -51,7 +54,7 @@ const CHILD_OVERRIDE_MAP: Partial> = { @@ -170,7 +173,7 @@ function applyNestedOverride( descriptor: ResourceDescriptor, json: Record, overrides: OverrideConfig, - mapping: { parentSection: keyof OverrideConfig; childKey: string } + mapping: { parentSection: OverrideSectionKey; childKey: string } ): Record { const parentSection = overrides[mapping.parentSection]; if (!parentSection) return { ...json }; @@ -218,7 +221,7 @@ function applyGrandchildOverride( descriptor: ResourceDescriptor, json: Record, overrides: OverrideConfig, - mapping: { parentSection: keyof OverrideConfig; childKey: string; grandchildKey: string } + mapping: { parentSection: OverrideSectionKey; childKey: string; grandchildKey: string } ): Record { const parentSection = overrides[mapping.parentSection]; if (!parentSection) return { ...json }; diff --git a/src/services/policy-ref-rewriter.ts b/src/services/policy-ref-rewriter.ts new file mode 100644 index 00000000..25e2119a --- /dev/null +++ b/src/services/policy-ref-rewriter.ts @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Rewrites cross-resource references inside APIM policy XML strings from + * canonical → deployed names. + * + * Design trade-off — regex, not XML parsing: + * Aligns with the repo's opaque-payload philosophy (FR-009). XML comments + * () and CDATA sections are NOT explicitly excluded; a token like + * {{name}} inside a comment would technically be rewritten. This risk is + * acceptable because the known-set filter is the primary safety guard — + * only names present in the canonical artifact set are rewritten, so a + * false positive requires an artifact to be named exactly like a comment + * token, which is extremely unlikely in practice. + * + * Known-set filter safety: + * Only tokens/attribute values whose exact text is present in the + * corresponding known set are rewritten. This naturally excludes APIM + * runtime context references such as {{context.request.headers.foo}} + * because those strings will never appear as artifact resource names. + */ + +import { ResourceType } from '../models/resource-types.js'; +import type { EnvMapping } from './env-mapper.js'; +import { toDeployedName } from './env-mapper.js'; +import type { KnownArtifactSets } from '../models/config.js'; + +// --------------------------------------------------------------------------- +// Regex patterns +// --------------------------------------------------------------------------- + +/** + * Matches {{token}} with optional whitespace inside the braces. + * Group 1 = token name (no whitespace). + */ +const NV_PATTERN = /\{\{\s*([A-Za-z0-9._-]+)\s*\}\}/g; + +/** + * Matches the fragment-id attribute on an element. + * Groups: 1 = tag prefix through `=`, 2 = opening quote, 3 = id value, + * 4 = closing quote (backreference to group 2). + */ +const FRAGMENT_PATTERN = /(]*\bfragment-id\s*=\s*)(["'])([^"'>]+)(\2)/gi; + +/** + * Matches the backend-id attribute on a element. + * Groups: 1 = tag prefix through `=`, 2 = opening quote, 3 = id value, + * 4 = closing quote (backreference to group 2). + */ +const BACKEND_PATTERN = /(]*\bbackend-id\s*=\s*)(["'])([^"'>]+)(\2)/gi; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export type { KnownArtifactSets } from '../models/config.js'; + +/** + * Rewrite cross-resource refs inside a policy XML string from canonical → + * deployed names. + * + * Handled reference styles: + * 1. `{{tokenName}}` — NamedValue reference. Only rewritten when + * tokenName ∈ knownArtifactSets.namedValues AND + * ResourceType.NamedValue ∈ mapping.appliesTo. + * 2. `fragment-id="name"` on . Only rewritten when + * name ∈ knownArtifactSets.fragments AND + * ResourceType.PolicyFragment ∈ mapping.appliesTo. + * 3. `backend-id="name"` on . Only rewritten when + * name ∈ knownArtifactSets.backends AND + * ResourceType.Backend ∈ mapping.appliesTo. + * + * Returns the input unchanged if `mapping` is undefined or no matches apply. + */ +export function rewritePolicyRefs( + xml: string, + mapping: EnvMapping | undefined, + known: KnownArtifactSets, +): string { + if (mapping === undefined) return xml; + + let result = xml; + + if (mapping.appliesTo.has(ResourceType.NamedValue)) { + result = result.replace(NV_PATTERN, (match, name: string) => { + if (!known.namedValues.has(name)) return match; + return `{{${toDeployedName(name, ResourceType.NamedValue, mapping)}}}`; + }); + } + + if (mapping.appliesTo.has(ResourceType.PolicyFragment)) { + result = result.replace(FRAGMENT_PATTERN, (match, prefix: string, quote: string, id: string) => { + if (!known.fragments.has(id)) return match; + return `${prefix}${quote}${toDeployedName(id, ResourceType.PolicyFragment, mapping)}${quote}`; + }); + } + + if (mapping.appliesTo.has(ResourceType.Backend)) { + result = result.replace(BACKEND_PATTERN, (match, prefix: string, quote: string, id: string) => { + if (!known.backends.has(id)) return match; + return `${prefix}${quote}${toDeployedName(id, ResourceType.Backend, mapping)}${quote}`; + }); + } + + return result; +} diff --git a/src/services/publish-service.ts b/src/services/publish-service.ts index ed9604c9..0184ec67 100644 --- a/src/services/publish-service.ts +++ b/src/services/publish-service.ts @@ -20,7 +20,7 @@ import { buildResourceLabel } from '../lib/resource-uri.js'; import { getNamePart, isChildType, isTopLevelSingleton } from '../lib/resource-path.js'; // Import from other agents' files (will be created in parallel) -import { publishResource, ResourcePublishResult } from './resource-publisher.js'; +import { publishResource, ResourcePublishResult, buildKnownArtifactSets } from './resource-publisher.js'; import { publishApi } from './api-publisher.js'; import { publishProduct } from './product-publisher.js'; import { generateDryRunReport, DryRunReport } from './dry-run-reporter.js'; @@ -29,6 +29,7 @@ import { computeGitDiff } from './git-diff-service.js'; import { scanForRedactionMarkers } from './secret-redaction-guard.js'; import { hasNamedValueOverride } from './override-merger.js'; import { REDACTION_MARKER } from './secret-redactor.js'; +import { validateAndBuildEnvMapping } from './env-mapping-validator.js'; /** * The APIM Backend properties.type value that identifies a pool backend. @@ -78,11 +79,28 @@ export async function runPublish( config ); + // Step 1a: For env-mapping validation and policy ref rewriting we need + // the FULL artifact set, not just the changed subset returned by + // incremental mode. Cross-references (policy tokens, fragment/backend + // refs) can point at named values / fragments / backends whose files + // didn't change in this commit — those must still be rewritten. + // Stale-override warnings likewise need the full set to avoid firing on + // every unchanged artifact in incremental runs. + const allDescriptors = config.commitId + ? await store.listResources(config.sourceDir) + : targetDescriptors; + + // Step 1b: Validate env mapping and store it on config for downstream use + validateAndBuildEnvMapping(config.overrides, allDescriptors, config); + + // Step 1c: Build known artifact sets for policy ref rewriting + config.knownArtifactSets = buildKnownArtifactSets(allDescriptors); + logger.debug( `Publishing ${targetDescriptors.length} resources (dry-run: ${config.dryRun})` ); - // Step 1b: Redaction pre-flight gate. + // Step 1c: Redaction pre-flight gate. // Scan every artifact that would be published for leftover redaction markers // ('*** REDACTED ***'). A single leftover marker aborts the ENTIRE publish // before any PUT is issued — and also fails dry-run — so a service can never diff --git a/src/services/resource-publisher.ts b/src/services/resource-publisher.ts index f41d0473..9fdfb963 100644 --- a/src/services/resource-publisher.ts +++ b/src/services/resource-publisher.ts @@ -20,8 +20,14 @@ 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 type { OverrideConfig } from '../models/config.js'; +import type { OverrideConfig, OverrideSection } from '../models/config.js'; import { buildResourceLabel } from '../lib/resource-uri.js'; +import { mapDescriptor, toDeployedName } from './env-mapper.js'; +import type { EnvMapping } from './env-mapper.js'; +import { rewritePolicyRefs } from './policy-ref-rewriter.js'; +import type { KnownArtifactSets } from '../models/config.js'; + +export type { KnownArtifactSets } from '../models/config.js'; export interface ResourcePublishResult { descriptor: ResourceDescriptor; @@ -30,6 +36,41 @@ export interface ResourcePublishResult { error?: Error; } +/** + * Build the known artifact name sets from a list of descriptors. + * Called once per publish run after determinePublishTargets. + */ +export function buildKnownArtifactSets(descriptors: ResourceDescriptor[]): KnownArtifactSets { + const namedValues = new Set(); + const fragments = new Set(); + const backends = new Set(); + for (const d of descriptors) { + if (d.nameParts.length > 0) { + const name = d.nameParts[0]; + if (d.type === ResourceType.NamedValue) namedValues.add(name); + else if (d.type === ResourceType.PolicyFragment) fragments.add(name); + else if (d.type === ResourceType.Backend) backends.add(name); + } + } + return { namedValues, fragments, backends }; +} + +/** + * Check if a specific property has an explicit override in the given section. + * Uses case-insensitive resource name matching (matching override-merger behavior). + */ +function hasExplicitPropertyOverride( + resourceName: string, + propertyKey: string, + section: OverrideSection | undefined, +): boolean { + if (!section) return false; + const lowerName = resourceName.toLowerCase(); + const matchingKey = Object.keys(section).find((k) => k.toLowerCase() === lowerName); + if (!matchingKey) return false; + return Object.hasOwn(section[matchingKey].properties, propertyKey); +} + /** * Policy resource types that have external XML content */ @@ -99,7 +140,7 @@ export async function publishResource( // In service scope, ApiTag is a regular PUT with the tag JSON; in workspace // scope it becomes a link resource at `tags/{tag}/apiLinks/{api}`. if (descriptor.type === ResourceType.ApiTag && (descriptor.workspace || isWorkspaceScope(context))) { - return await publishWorkspaceApiTagLink(client, context, descriptor); + return await publishWorkspaceApiTagLink(client, context, descriptor, config); } // Handle wiki types @@ -125,7 +166,7 @@ export async function publishResource( // Rewrite MCP tool operationIds against the target service before overrides // so explicit override values win unchanged. if (descriptor.type === ResourceType.Api) { - json = normalizeMcpToolOperationIds(json, context); + json = normalizeMcpToolOperationIds(json, context, config.envMapping); } // Apply overrides (deep merge, preserves opaque structure) @@ -135,7 +176,7 @@ export async function publishResource( // APIM resolves {{ref}} by displayName, not resource name. Old extracts // stored {{name}}; rewrite to {{displayName}} for APIM compatibility. if (descriptor.type === ResourceType.Logger) { - json = await rewriteNamedValueReferences(json, store, config.sourceDir, descriptor, config.overrides); + json = await rewriteNamedValueReferences(json, store, config.sourceDir, descriptor, config.overrides, config.envMapping); } // API operations: enforce explicit empty strings for text fields when omitted. @@ -152,6 +193,32 @@ export async function publishResource( // to the secret before attempting the PUT. Surfaces permission errors // early and fails fast instead of polling until timeout. if (descriptor.type === ResourceType.NamedValue) { + // Affix displayName when envMapping applies and user hasn't set an explicit override. + // APIM resolves {{ref}} tokens by displayName, so the deployed displayName must + // match the affixed name used elsewhere (descriptor name, policy refs, etc.). + if (config.envMapping?.appliesTo.has(ResourceType.NamedValue) === true) { + const canonicalName = getNamePart(descriptor.nameParts, 0); + const hasDisplayNameOverride = hasExplicitPropertyOverride( + canonicalName, + 'displayName', + config.overrides?.namedValues, + ); + if (!hasDisplayNameOverride) { + const props = json.properties as Record | undefined; + const currentDisplayName = + typeof props?.displayName === 'string' ? props.displayName : canonicalName; + const deployedDisplayName = toDeployedName( + currentDisplayName, + ResourceType.NamedValue, + config.envMapping, + ); + json = { + ...json, + properties: { ...(props ?? {}), displayName: deployedDisplayName }, + }; + } + } + const props = json.properties as Record | undefined; const kvBlock = props?.keyVault as Record | undefined; if (kvBlock != null) { @@ -223,7 +290,7 @@ export async function publishResource( }; } - json = normalizeSubscriptionScope(json, context); + json = normalizeSubscriptionScope(json, context, config.envMapping); } // ApiRelease: normalize properties.apiId from source ARM path to target ARM path. @@ -233,7 +300,7 @@ export async function publishResource( // The PUT to the target service must reference the target service path, // otherwise APIM rejects the request with a validation error. if (descriptor.type === ResourceType.ApiRelease) { - json = normalizeApiReleaseApiId(json, context); + json = normalizeApiReleaseApiId(json, context, config.envMapping); } // API Revisions (e.g., "my-api;rev=2") need sourceApiId so APIM knows which @@ -248,7 +315,10 @@ export async function publishResource( for (const [key, val] of Object.entries(props ?? {})) { if (val !== null) cleanProps[key] = val; } - cleanProps.sourceApiId = `/subscriptions/${context.subscriptionId}/resourceGroups/${context.resourceGroup}/providers/Microsoft.ApiManagement/service/${context.serviceName}/apis/${baseApiName}`; + const deployedBaseApiName = config.envMapping + ? toDeployedName(baseApiName, ResourceType.Api, config.envMapping) + : baseApiName; + cleanProps.sourceApiId = `/subscriptions/${context.subscriptionId}/resourceGroups/${context.resourceGroup}/providers/Microsoft.ApiManagement/service/${context.serviceName}/apis/${deployedBaseApiName}`; // Preserve source current-revision intent. APIM can implicitly promote a // created revision to current if isCurrent is omitted; default to false // unless the extracted artifact explicitly set it. @@ -259,8 +329,41 @@ export async function publishResource( } } + // Apply api path prefix when envMapping provides one and no per-API path override + if (descriptor.type === ResourceType.Api && config.envMapping?.apiPathPrefix !== undefined) { + const canonicalApiName = getNamePart(descriptor.nameParts, 0).split(';rev=')[0]; + const hasPathOverride = hasExplicitPropertyOverride(canonicalApiName, 'path', config.overrides?.apis); + if (!hasPathOverride) { + const props = json.properties as Record | undefined; + if (typeof props?.path === 'string') { + const rawPath = props.path; + const prefix = config.envMapping.apiPathPrefix; + // Avoid double slashes + const newPath = + prefix.endsWith('/') && rawPath.startsWith('/') + ? prefix + rawPath.slice(1) + : prefix + rawPath; + json = { ...json, properties: { ...props, path: newPath } }; + } + } + } + + // For PolicyFragment: rewrite cross-resource refs in properties.value (policy XML) + if (descriptor.type === ResourceType.PolicyFragment && config.envMapping && config.knownArtifactSets) { + const props = json.properties as Record | undefined; + if (typeof props?.value === 'string') { + const rewrittenXml = rewritePolicyRefs(props.value, config.envMapping, config.knownArtifactSets); + json = { ...json, properties: { ...props, value: rewrittenXml } }; + } + } + + // Apply env-mapping: affix descriptor name segments before PUT + const deployedDescriptor = config.envMapping + ? mapDescriptor(descriptor, config.envMapping) + : descriptor; + // PUT to APIM - await client.putResource(context, descriptor, json); + await client.putResource(context, deployedDescriptor, json); return { descriptor, @@ -306,10 +409,14 @@ async function publishAssociation( // Create association for each name for (const entry of entries) { - const assocDescriptor: ResourceDescriptor = { + const rawAssocDescriptor: ResourceDescriptor = { type: descriptor.type, nameParts: [getNamePart(descriptor.nameParts, 0), entry.name], }; + // Apply env-mapping to affix both the parent and child name segments + const assocDescriptor = config.envMapping + ? mapDescriptor(rawAssocDescriptor, config.envMapping) + : rawAssocDescriptor; try { // PUT empty body for association (APIM uses PUT to create association) await client.putResource(context, assocDescriptor, {}); @@ -373,7 +480,10 @@ async function publishWiki( }, }; - await client.putResource(context, descriptor, payload); + const deployedDescriptor = config.envMapping + ? mapDescriptor(descriptor, config.envMapping) + : descriptor; + await client.putResource(context, deployedDescriptor, payload); return { descriptor, @@ -428,11 +538,24 @@ async function publishPolicy( }; // Apply overrides (e.g., format: xml) before PUT — matches Toolkit behavior - const mergedPayload = applyOverrides(descriptor, payload, config.overrides); + let mergedPayload = applyOverrides(descriptor, payload, config.overrides); + + // Rewrite policy XML references from canonical → deployed names + if (config.envMapping && config.knownArtifactSets) { + const mProps = mergedPayload.properties as Record | undefined; + const mValue = mProps?.value; + if (typeof mValue === 'string') { + const rewrittenXml = rewritePolicyRefs(mValue, config.envMapping, config.knownArtifactSets); + mergedPayload = { + ...mergedPayload, + properties: { ...(mergedPayload.properties as object), value: rewrittenXml }, + }; + } + } - // Marker check runs AFTER overrides: an override may legitimately replace the - // policy value with clean content, so only the merged (about-to-be-published) - // value is authoritative for redaction detection. + // Marker check runs AFTER overrides and rewriting: an override may legitimately + // replace the policy value with clean content, so only the merged + // (about-to-be-published) value is authoritative for redaction detection. const mergedProps = mergedPayload.properties as Record | undefined; const mergedValue = mergedProps?.value; if (typeof mergedValue === 'string' && mergedValue.includes(REDACTION_MARKER)) { @@ -443,7 +566,11 @@ async function publishPolicy( ); } - await client.putResource(context, descriptor, mergedPayload); + // Apply env-mapping: affix descriptor name segments before PUT + const deployedDescriptor = config.envMapping + ? mapDescriptor(descriptor, config.envMapping) + : descriptor; + await client.putResource(context, deployedDescriptor, mergedPayload); return { descriptor, @@ -476,7 +603,8 @@ async function publishPolicy( */ function normalizeSubscriptionScope( json: Record, - context: ApimServiceContext + context: ApimServiceContext, + envMapping?: EnvMapping ): Record { const props = json.properties as Record | undefined; if (!props) return json; @@ -489,7 +617,23 @@ function normalizeSubscriptionScope( const armPathPrefix = context.baseUrl.replace(/^https?:\/\/[^/]+/, ''); if (scope.startsWith(armPathPrefix)) { - const relativeScope = scope.slice(armPathPrefix.length) || '/'; + let relativeScope = scope.slice(armPathPrefix.length) || '/'; + + // Affix the trailing resource name when envMapping applies + if (envMapping && relativeScope !== '/') { + if (relativeScope.startsWith('/apis/')) { + const apiName = relativeScope.slice('/apis/'.length); + if (apiName) { + relativeScope = `/apis/${toDeployedName(apiName, ResourceType.Api, envMapping)}`; + } + } else if (relativeScope.startsWith('/products/')) { + const productName = relativeScope.slice('/products/'.length); + if (productName) { + relativeScope = `/products/${toDeployedName(productName, ResourceType.Product, envMapping)}`; + } + } + } + return { ...json, properties: { ...props, scope: relativeScope }, @@ -514,7 +658,8 @@ function normalizeSubscriptionScope( */ function normalizeApiReleaseApiId( json: Record, - context: ApimServiceContext + context: ApimServiceContext, + envMapping?: EnvMapping ): Record { const props = json.properties as Record | undefined; if (!props) return json; @@ -529,7 +674,19 @@ function normalizeApiReleaseApiId( // The suffix includes the full API name, e.g. "/apis/my-api;rev=2". const apisIndex = apiId.indexOf('/apis/'); if (apisIndex !== -1) { - const apisSuffix = apiId.slice(apisIndex); + const rawApisSuffix = apiId.slice(apisIndex); // e.g. "/apis/my-api;rev=2" + let apisSuffix = rawApisSuffix; + + if (envMapping) { + // Extract api name from "/apis/" possibly with ";rev=N" + const afterApis = rawApisSuffix.slice('/apis/'.length); + const revSepIdx = afterApis.indexOf(';rev='); + const baseApiName = revSepIdx !== -1 ? afterApis.slice(0, revSepIdx) : afterApis; + const revSuffix = revSepIdx !== -1 ? afterApis.slice(revSepIdx) : ''; + const deployedName = toDeployedName(baseApiName, ResourceType.Api, envMapping); + apisSuffix = `/apis/${deployedName}${revSuffix}`; + } + return { ...json, properties: { ...props, apiId: targetArmPrefix + apisSuffix }, @@ -548,7 +705,8 @@ function normalizeApiReleaseApiId( */ export function normalizeMcpToolOperationIds( json: Record, - context: ApimServiceContext + context: ApimServiceContext, + envMapping?: EnvMapping ): Record { const props = json.properties as Record | undefined; if (!props) return json; @@ -572,9 +730,26 @@ export function normalizeMcpToolOperationIds( return typedTool; } + const rebuiltOpId = `${targetArmPrefix}${operationId.slice(operationsIndex)}`; + let finalOpId = rebuiltOpId; + + if (envMapping) { + // Affix the api name segment (between /apis/ and /operations/) + const apisIdx = rebuiltOpId.indexOf('/apis/'); + const opsIdx = rebuiltOpId.indexOf('/operations/', apisIdx !== -1 ? apisIdx : 0); + if (apisIdx !== -1 && opsIdx !== -1) { + const apiName = rebuiltOpId.slice(apisIdx + '/apis/'.length, opsIdx); + const deployedApiName = toDeployedName(apiName, ResourceType.Api, envMapping); + finalOpId = + rebuiltOpId.slice(0, apisIdx + '/apis/'.length) + + deployedApiName + + rebuiltOpId.slice(opsIdx); + } + } + return { ...typedTool, - operationId: `${targetArmPrefix}${operationId.slice(operationsIndex)}`, + operationId: finalOpId, }; }); @@ -631,18 +806,25 @@ function isAutoGeneratedProductSubscription( async function publishWorkspaceApiTagLink( client: IApimClient, context: ApimServiceContext, - descriptor: ResourceDescriptor + descriptor: ResourceDescriptor, + config: PublishConfig ): Promise { try { - const apiName = getNamePart(descriptor.nameParts, 0); + const canonicalApiName = getNamePart(descriptor.nameParts, 0); + const deployedApiName = config.envMapping + ? toDeployedName(canonicalApiName, ResourceType.Api, config.envMapping) + : canonicalApiName; const meta = RESOURCE_TYPE_METADATA[ResourceType.ApiTag]; if (!meta.workspaceLinkIdProperty) { throw new Error(`Missing workspaceLinkIdProperty in metadata for ${ResourceType.ApiTag}`); } - const payload = buildLinkPayload(context, meta.workspaceLinkIdProperty, 'apis', apiName, descriptor.workspace); + const payload = buildLinkPayload(context, meta.workspaceLinkIdProperty, 'apis', deployedApiName, descriptor.workspace); + const deployedDescriptor = config.envMapping + ? mapDescriptor(descriptor, config.envMapping) + : descriptor; try { - await client.putResource(context, descriptor, payload); + await client.putResource(context, deployedDescriptor, payload); } catch (error) { // 409 means the tag/api link already exists — desired state is in place. if (!isLinkAlreadyExistsError(error)) { @@ -684,6 +866,7 @@ async function rewriteNamedValueReferences( sourceDir: string, descriptor: ResourceDescriptor, overrides?: OverrideConfig, + envMapping?: EnvMapping, ): Promise> { const props = json.properties as Record | undefined; const credentials = props?.credentials; @@ -712,7 +895,22 @@ async function rewriteNamedValueReferences( // Apply any named value overrides so displayName reflects user config nvJson = applyOverrides(nvDescriptor, nvJson, overrides); const nvProps = nvJson.properties as Record | undefined; - const displayName = nvProps?.displayName as string | undefined; + let displayName = nvProps?.displayName as string | undefined; + + // If envMapping applies to NamedValue and no explicit displayName override, + // use the affixed displayName so {{token}} refs resolve on the shared APIM. + if (envMapping?.appliesTo.has(ResourceType.NamedValue)) { + const hasDisplayNameOverride = hasExplicitPropertyOverride( + ref, + 'displayName', + overrides?.namedValues, + ); + if (!hasDisplayNameOverride) { + const baseDisplayName = displayName ?? ref; + displayName = toDeployedName(baseDisplayName, ResourceType.NamedValue, envMapping); + } + } + if (displayName && displayName !== ref) { rewriteMap.set(ref, displayName); } diff --git a/src/templates/configs/override-config.yaml b/src/templates/configs/override-config.yaml index 369e04a9..fe8292f0 100644 --- a/src/templates/configs/override-config.yaml +++ b/src/templates/configs/override-config.yaml @@ -4,6 +4,19 @@ # For full format details and examples, see: # https://github.com/Azure/apiops-cli/blob/main/docs/guides/environment-overrides.md +# Uncomment this block only when publishing multiple environments (dev/qa/prod) +# to a single shared APIM instance. For dedicated-APIM-per-environment setups +# (the default), leave commented out. +# See: https://github.com/Azure/apiops-cli/blob/main/docs/guides/multi-environment-shared-apim.md +# +# environment: +# namePrefix: "{{ENVIRONMENT}}-" # e.g. "dev-", "qa-", "prod-" +# # nameSuffix: "" # optional +# apiPathPrefix: "{{ENVIRONMENT}}/" # applied to Api properties.path unless per-API override present +# # appliesTo: # optional; defaults to Api, Product, NamedValue, Backend, Logger, +# # - Api # PolicyFragment, VersionSet, Tag, Group, Subscription, Workspace +# # - Product + # Override named values (e.g., API keys, connection strings) # namedValues: # - name: api-key diff --git a/src/templates/copilot/configure-overrides-prompt.md b/src/templates/copilot/configure-overrides-prompt.md index 545bbaed..952b171f 100644 --- a/src/templates/copilot/configure-overrides-prompt.md +++ b/src/templates/copilot/configure-overrides-prompt.md @@ -11,6 +11,8 @@ description: 'Configure APIOps environment overrides' Create one `configuration.{environment}.yaml` file per deployment environment so APIOps publish runs can promote the same artifacts across environments with environment-specific settings. +> **Shared APIM setup:** If all environments publish to a **single shared APIM instance** (dev/qa/prod in one service), each override file should also have its `environment:` block uncommented with a unique `namePrefix` (e.g. `"dev-"`) and `apiPathPrefix` (e.g. `"dev/"`) so resource names and API paths are namespaced per environment. Ask the user whether they use a shared or dedicated APIM instance. For dedicated setups (one APIM per environment), leave the `environment:` block commented out. See `docs/guides/multi-environment-shared-apim.md` for full guidance. + --- ## How Copilot must work through this prompt diff --git a/tests/unit/lib/config-loader.test.ts b/tests/unit/lib/config-loader.test.ts index cb1b4a28..47fd3e54 100644 --- a/tests/unit/lib/config-loader.test.ts +++ b/tests/unit/lib/config-loader.test.ts @@ -558,4 +558,112 @@ namedValues: ); }); }); + + describe('loadOverrideConfig — environment field', () => { + it('should load environment with namePrefix', async () => { + const content = ` +environment: + namePrefix: "dev-" +`; + const filePath = path.join(tmpDir, 'override-env-prefix.yaml'); + await fs.writeFile(filePath, content, 'utf-8'); + + const config = await loadOverrideConfig(filePath); + expect(config).toBeDefined(); + expect(config!.environment).toEqual({ namePrefix: 'dev-' }); + }); + + it('should load environment with all fields', async () => { + const content = ` +environment: + namePrefix: "dev-" + nameSuffix: "-dev" + apiPathPrefix: "dev/" + appliesTo: + - Api + - Product +`; + const filePath = path.join(tmpDir, 'override-env-all.yaml'); + await fs.writeFile(filePath, content, 'utf-8'); + + const config = await loadOverrideConfig(filePath); + expect(config!.environment).toEqual({ + namePrefix: 'dev-', + nameSuffix: '-dev', + apiPathPrefix: 'dev/', + appliesTo: ['Api', 'Product'], + }); + }); + + it('should load override config without environment (back-compat)', async () => { + const content = ` +namedValues: + - name: nv1 + properties: + value: "overridden" +`; + const filePath = path.join(tmpDir, 'override-no-env.yaml'); + await fs.writeFile(filePath, content, 'utf-8'); + + const config = await loadOverrideConfig(filePath); + expect(config).toBeDefined(); + expect(config!.environment).toBeUndefined(); + expect(config!.namedValues).toBeDefined(); + }); + + it('should throw when environment is not an object', async () => { + const content = ` +environment: "not-an-object" +`; + const filePath = path.join(tmpDir, 'override-env-invalid.yaml'); + await fs.writeFile(filePath, content, 'utf-8'); + + await expect(loadOverrideConfig(filePath)).rejects.toThrow( + "'environment' must be an object" + ); + }); + + it('should throw when environment.namePrefix is not a string', async () => { + const content = ` +environment: + namePrefix: 123 +`; + const filePath = path.join(tmpDir, 'override-env-bad-prefix.yaml'); + await fs.writeFile(filePath, content, 'utf-8'); + + await expect(loadOverrideConfig(filePath)).rejects.toThrow( + "'environment.namePrefix' must be a string" + ); + }); + + it('should throw when environment.appliesTo contains non-strings', async () => { + const content = ` +environment: + appliesTo: + - Api + - 123 +`; + const filePath = path.join(tmpDir, 'override-env-bad-appliesto.yaml'); + await fs.writeFile(filePath, content, 'utf-8'); + + await expect(loadOverrideConfig(filePath)).rejects.toThrow('must be a string'); + }); + + it('should warn about unknown environment keys', async () => { + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + const content = ` +environment: + namePrefix: "dev-" + unknownField: "value" +`; + const filePath = path.join(tmpDir, 'override-env-unknown-key.yaml'); + await fs.writeFile(filePath, content, 'utf-8'); + + const config = await loadOverrideConfig(filePath); + expect(config!.environment).toEqual({ namePrefix: 'dev-' }); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("Unknown environment override key 'unknownField'") + ); + }); + }); }); diff --git a/tests/unit/models/config.environment.test.ts b/tests/unit/models/config.environment.test.ts new file mode 100644 index 00000000..07f621aa --- /dev/null +++ b/tests/unit/models/config.environment.test.ts @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +import { describe, it, expect, beforeAll } from 'vitest'; +import Ajv from 'ajv'; +import { readFile } from 'node:fs/promises'; + +const schemaPath = new URL('../../../schemas/v1/override-config.schema.json', import.meta.url); + +describe('override-config schema — environment property', () => { + let validate: ReturnType; + + beforeAll(async () => { + const schemaJson = JSON.parse(await readFile(schemaPath, 'utf-8')); + const ajv = new Ajv({ strict: false }); + validate = ajv.compile(schemaJson); + }); + + it('accepts environment with namePrefix only', () => { + const valid = validate({ environment: { namePrefix: 'dev-' } }); + expect(valid).toBe(true); + }); + + it('accepts environment with all fields', () => { + const valid = validate({ + environment: { + namePrefix: 'dev-', + nameSuffix: '-dev', + apiPathPrefix: 'dev/', + appliesTo: ['Api', 'Product'], + }, + }); + expect(valid).toBe(true); + }); + + it('accepts config without environment (back-compat)', () => { + const valid = validate({ namedValues: [{ name: 'nv1', properties: { value: 'x' } }] }); + expect(valid).toBe(true); + }); + + it('rejects namePrefix with invalid character (e.g. "@")', () => { + const valid = validate({ environment: { namePrefix: 'dev@' } }); + expect(valid).toBe(false); + }); + + it('rejects nameSuffix with invalid character (e.g. " ")', () => { + const valid = validate({ environment: { nameSuffix: 'dev env' } }); + expect(valid).toBe(false); + }); + + it('rejects appliesTo containing an unknown resource type', () => { + const valid = validate({ environment: { appliesTo: ['Api', 'UnknownType'] } }); + expect(valid).toBe(false); + }); + + it('rejects unknown property at root (additionalProperties: false)', () => { + const valid = validate({ unknownTopLevelProp: 'value' }); + expect(valid).toBe(false); + }); + + it('rejects unknown property inside environment (additionalProperties: false)', () => { + const valid = validate({ environment: { namePrefix: 'dev-', bogus: true } }); + expect(valid).toBe(false); + }); +}); diff --git a/tests/unit/services/delete-unmatched.env-mapping.test.ts b/tests/unit/services/delete-unmatched.env-mapping.test.ts new file mode 100644 index 00000000..9d114e6d --- /dev/null +++ b/tests/unit/services/delete-unmatched.env-mapping.test.ts @@ -0,0 +1,326 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +/** + * Unit tests for delete-unmatched env-namespace scoping (T7) + * + * Verifies that computeDeleteActions correctly restricts deletions to the + * current env's namespace when config.envMapping is present, and falls back to + * the original behaviour when it is absent. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { computeDeleteActions } from '../../../src/services/delete-unmatched-service.js'; +import { ResourceType } from '../../../src/models/resource-types.js'; +import type { ApimServiceContext, ResourceDescriptor } from '../../../src/models/types.js'; +import type { PublishConfig } from '../../../src/models/config.js'; +import type { EnvMapping } from '../../../src/services/env-mapper.js'; +import { LogLevel } from '../../../src/lib/logger.js'; + +// ── Mock helpers ────────────────────────────────────────────────────────────── + +function createMockClient( + apimResources: Map[]> = new Map() +) { + return { + listResources: async function* (ctx: ApimServiceContext, type: ResourceType) { + const resources = apimResources.get(type) ?? []; + for (const resource of resources) { + yield resource; + } + }, + getResource: vi.fn(), + putResource: vi.fn(), + deleteResource: vi.fn(), + patchResource: vi.fn().mockResolvedValue(undefined), + listApiRevisions: async function* () {}, + getApiSpecification: vi.fn(), + validatePreFlight: vi.fn().mockResolvedValue(undefined), + }; +} + +function createMockStore(localDescriptors: ResourceDescriptor[] = []) { + return { + writeResource: vi.fn(), + writeContent: vi.fn(), + writeAssociation: vi.fn(), + readResource: vi.fn(), + readContent: vi.fn(), + readAssociation: vi.fn().mockResolvedValue([]), + listResources: vi.fn().mockResolvedValue(localDescriptors), + deleteResource: vi.fn(), + }; +} + +const testContext: ApimServiceContext = { + subscriptionId: 'sub-1', + resourceGroup: 'rg-1', + serviceName: 'apim-shared', + apiVersion: '2024-05-01', + baseUrl: 'https://management.azure.com/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.ApiManagement/service/apim-shared', +}; + +/** Config with NO envMapping — exercises original code path. */ +const baseConfig: PublishConfig = { + service: testContext, + sourceDir: '/source', + dryRun: false, + deleteUnmatched: true, + logLevel: LogLevel.INFO, +}; + +/** A mapping for the "dev" environment: prefix `dev-`, all default types. */ +const devMapping: EnvMapping = { + prefix: 'dev-', + suffix: '', + appliesTo: new Set([ + ResourceType.Api, + ResourceType.Product, + ResourceType.NamedValue, + ResourceType.Backend, + ResourceType.Logger, + ResourceType.PolicyFragment, + ResourceType.VersionSet, + ResourceType.Tag, + ResourceType.Group, + ResourceType.Subscription, + ResourceType.Workspace, + ]), +}; + +function devConfig(overrides?: Partial): PublishConfig { + return { ...baseConfig, envMapping: devMapping, ...overrides }; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('delete-unmatched env-namespace scoping', () => { + describe('no envMapping → original behaviour (regression)', () => { + it('deletes resources missing from local artifacts', async () => { + const apimResources = new Map[]>([ + [ResourceType.Api, [ + { name: 'foo', id: '/apis/foo' }, + { name: 'bar', id: '/apis/bar' }, + ]], + ]); + const client = createMockClient(apimResources); + const store = createMockStore([ + { type: ResourceType.Api, nameParts: ['foo'] }, + ]); + + const result = await computeDeleteActions(client, store, testContext, baseConfig); + + const names = result.map((d) => d.nameParts[0]); + expect(names).not.toContain('foo'); + expect(names).toContain('bar'); + }); + + it('does not delete built-in groups', async () => { + const apimResources = new Map[]>([ + [ResourceType.Group, [ + { name: 'administrators' }, + { name: 'custom-group' }, + ]], + ]); + const client = createMockClient(apimResources); + const store = createMockStore([]); + + const result = await computeDeleteActions(client, store, testContext, baseConfig); + const names = result.map((d) => d.nameParts[0]); + expect(names).not.toContain('administrators'); + expect(names).toContain('custom-group'); + }); + }); + + describe('envMapping present — namespace scoping', () => { + it('deletes dev-foo but skips prod-foo when local artifacts are empty', async () => { + const apimResources = new Map[]>([ + [ResourceType.Api, [ + { name: 'dev-foo' }, + { name: 'prod-foo' }, + ]], + ]); + const client = createMockClient(apimResources); + const store = createMockStore([]); // no local APIs + + const result = await computeDeleteActions(client, store, testContext, devConfig()); + + const apiNames = result.filter((d) => d.type === ResourceType.Api).map((d) => d.nameParts[0]); + expect(apiNames).toContain('dev-foo'); + expect(apiNames).not.toContain('prod-foo'); + }); + + it('does NOT delete dev-foo when canonical foo is in local artifacts', async () => { + const apimResources = new Map[]>([ + [ResourceType.Api, [{ name: 'dev-foo' }]], + ]); + const client = createMockClient(apimResources); + const store = createMockStore([ + { type: ResourceType.Api, nameParts: ['foo'] }, // canonical local artifact + ]); + + const result = await computeDeleteActions(client, store, testContext, devConfig()); + + const apiNames = result.filter((d) => d.type === ResourceType.Api).map((d) => d.nameParts[0]); + expect(apiNames).not.toContain('dev-foo'); + }); + + it('deletes dev-bar (in namespace, canonical bar absent) using deployed name', async () => { + const apimResources = new Map[]>([ + [ResourceType.Api, [ + { name: 'dev-foo' }, // canonical foo → in local → not deleted + { name: 'dev-bar' }, // canonical bar → not in local → deleted + ]], + ]); + const client = createMockClient(apimResources); + const store = createMockStore([ + { type: ResourceType.Api, nameParts: ['foo'] }, + ]); + + const result = await computeDeleteActions(client, store, testContext, devConfig()); + + const apiResults = result.filter((d) => d.type === ResourceType.Api); + expect(apiResults).toHaveLength(1); + // Delete descriptor must carry the deployed name so the APIM client deletes the right resource. + expect(apiResults[0]?.nameParts[0]).toBe('dev-bar'); + }); + + it('type not in appliesTo (Diagnostic) — treated as today (no namespace filter)', async () => { + const apimResources = new Map[]>([ + [ResourceType.Diagnostic, [ + { name: 'applicationinsights' }, // Diagnostic ∉ devMapping.appliesTo + { name: 'azuremonitor' }, + ]], + ]); + const client = createMockClient(apimResources); + const store = createMockStore([ + { type: ResourceType.Diagnostic, nameParts: ['applicationinsights'] }, + ]); + + const result = await computeDeleteActions(client, store, testContext, devConfig()); + + const diagNames = result + .filter((d) => d.type === ResourceType.Diagnostic) + .map((d) => d.nameParts[0]); + // applicationinsights is in local → NOT deleted; azuremonitor is absent → deleted + expect(diagNames).not.toContain('applicationinsights'); + expect(diagNames).toContain('azuremonitor'); + }); + + it('ProductApi: skips when canonical parent+child match local (canonical match)', async () => { + // APIM lists product dev-starter with API dev-foo + const apimResources = new Map[]>([ + [ResourceType.ProductApi, [ + { nameParts: ['dev-starter', 'dev-foo'] }, + ]], + ]); + const client = createMockClient(apimResources); + // Local artifacts store canonical names: starter + foo + const store = createMockStore([ + { type: ResourceType.ProductApi, nameParts: ['starter', 'foo'] }, + ]); + + const result = await computeDeleteActions(client, store, testContext, devConfig()); + + const productApiResults = result.filter((d) => d.type === ResourceType.ProductApi); + expect(productApiResults).toHaveLength(0); + }); + + it('ProductApi: deletes deployed nameParts when child differs from local', async () => { + // APIM: dev-starter/dev-baz; local only has starter/foo + const apimResources = new Map[]>([ + [ResourceType.ProductApi, [ + { nameParts: ['dev-starter', 'dev-baz'] }, + ]], + ]); + const client = createMockClient(apimResources); + const store = createMockStore([ + { type: ResourceType.ProductApi, nameParts: ['starter', 'foo'] }, + ]); + + const result = await computeDeleteActions(client, store, testContext, devConfig()); + + const productApiResults = result.filter((d) => d.type === ResourceType.ProductApi); + expect(productApiResults).toHaveLength(1); + // Deployed nameParts must be used so the right resource is deleted in APIM + expect(productApiResults[0]?.nameParts).toEqual(['dev-starter', 'dev-baz']); + }); + + it('ProductApi: skips prod-starter/prod-baz (parent outside env namespace)', async () => { + const apimResources = new Map[]>([ + [ResourceType.ProductApi, [ + { nameParts: ['prod-starter', 'prod-baz'] }, + { nameParts: ['dev-starter', 'dev-baz'] }, + ]], + ]); + const client = createMockClient(apimResources); + const store = createMockStore([]); // no local artifacts → all in-namespace resources would be deleted + + const result = await computeDeleteActions(client, store, testContext, devConfig()); + + const productApiResults = result.filter((d) => d.type === ResourceType.ProductApi); + const parents = productApiResults.map((d) => d.nameParts[0]); + expect(parents).not.toContain('prod-starter'); + expect(parents).toContain('dev-starter'); + }); + + it('built-in group administrators never deleted even under env mapping', async () => { + // administrators has no dev- prefix — it is either outside namespace (skipped) + // or caught by the system-resource guard if Group ∉ appliesTo. + const apimResources = new Map[]>([ + [ResourceType.Group, [ + { name: 'administrators' }, + { name: 'dev-custom-group' }, + ]], + ]); + const client = createMockClient(apimResources); + const store = createMockStore([]); // no local groups + + const result = await computeDeleteActions(client, store, testContext, devConfig()); + + const groupNames = result.filter((d) => d.type === ResourceType.Group).map((d) => d.nameParts[0]); + expect(groupNames).not.toContain('administrators'); + expect(groupNames).toContain('dev-custom-group'); + }); + + it('system API echo-api (prefixed as dev-echo-api) never deleted', async () => { + // With env mapping: canonical of dev-echo-api is echo-api → system resource → skipped + const apimResources = new Map[]>([ + [ResourceType.Api, [ + { name: 'dev-echo-api' }, + { name: 'dev-my-api' }, + ]], + ]); + const client = createMockClient(apimResources); + const store = createMockStore([]); // no local APIs + + const result = await computeDeleteActions(client, store, testContext, devConfig()); + + const apiNames = result.filter((d) => d.type === ResourceType.Api).map((d) => d.nameParts[0]); + expect(apiNames).not.toContain('dev-echo-api'); + expect(apiNames).toContain('dev-my-api'); + }); + + it('mixed env listing: multiple resource types scoped correctly', async () => { + const apimResources = new Map[]>([ + [ResourceType.Api, [ + { name: 'dev-foo' }, + { name: 'prod-bar' }, + ]], + [ResourceType.Tag, [ + { name: 'dev-tag1' }, + { name: 'qa-tag2' }, + ]], + ]); + const client = createMockClient(apimResources); + const store = createMockStore([]); + + const result = await computeDeleteActions(client, store, testContext, devConfig()); + + const names = result.map((d) => d.nameParts[0]); + expect(names).toContain('dev-foo'); + expect(names).toContain('dev-tag1'); + expect(names).not.toContain('prod-bar'); + expect(names).not.toContain('qa-tag2'); + }); + }); +}); diff --git a/tests/unit/services/env-mapper.test.ts b/tests/unit/services/env-mapper.test.ts new file mode 100644 index 00000000..536c52d6 --- /dev/null +++ b/tests/unit/services/env-mapper.test.ts @@ -0,0 +1,461 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { describe, it, expect } from 'vitest'; +import { ResourceType } from '../../../src/models/resource-types.js'; +import type { ResourceDescriptor } from '../../../src/models/types.js'; +import type { OverrideConfig } from '../../../src/models/config.js'; +import { + DEFAULT_APPLIES_TO, + NON_AFFIXABLE_TYPES, + buildEnvMapping, + buildEnvMappingFromOverrides, + toDeployedName, + toCanonicalName, + isInEnvNamespace, + mapDescriptor, + type EnvironmentOverride, + type EnvMapping, +} from '../../../src/services/env-mapper.js'; + +// Helper to build a simple prefix-only mapping +function prefixMapping(prefix: string, types?: ResourceType[]): EnvMapping { + return buildEnvMapping({ namePrefix: prefix, appliesTo: types })!; +} + +// Helper to build a suffix-only mapping +function suffixMapping(suffix: string): EnvMapping { + return buildEnvMapping({ nameSuffix: suffix })!; +} + +// Helper to build a prefix+suffix mapping +function bothMapping(prefix: string, suffix: string): EnvMapping { + return buildEnvMapping({ namePrefix: prefix, nameSuffix: suffix })!; +} + +describe('env-mapper', () => { + // ─── DEFAULT_APPLIES_TO ────────────────────────────────────────────────── + describe('DEFAULT_APPLIES_TO', () => { + const expected = [ + ResourceType.Api, + ResourceType.Product, + ResourceType.NamedValue, + ResourceType.Backend, + ResourceType.Logger, + ResourceType.PolicyFragment, + ResourceType.VersionSet, + ResourceType.Tag, + ResourceType.Group, + ResourceType.Subscription, + ResourceType.Workspace, + ]; + + it('should contain exactly the 11 expected types', () => { + expect(DEFAULT_APPLIES_TO.size).toBe(11); + for (const t of expected) { + expect(DEFAULT_APPLIES_TO.has(t)).toBe(true); + } + }); + + it('should NOT include opt-in types (Gateway, Diagnostic, GlobalSchema, etc.)', () => { + expect(DEFAULT_APPLIES_TO.has(ResourceType.Gateway)).toBe(false); + expect(DEFAULT_APPLIES_TO.has(ResourceType.Diagnostic)).toBe(false); + expect(DEFAULT_APPLIES_TO.has(ResourceType.GlobalSchema)).toBe(false); + expect(DEFAULT_APPLIES_TO.has(ResourceType.PolicyRestriction)).toBe(false); + expect(DEFAULT_APPLIES_TO.has(ResourceType.Documentation)).toBe(false); + }); + }); + + // ─── NON_AFFIXABLE_TYPES ───────────────────────────────────────────────── + describe('NON_AFFIXABLE_TYPES', () => { + it('should include singleton policies', () => { + expect(NON_AFFIXABLE_TYPES.has(ResourceType.ServicePolicy)).toBe(true); + expect(NON_AFFIXABLE_TYPES.has(ResourceType.ApiPolicy)).toBe(true); + expect(NON_AFFIXABLE_TYPES.has(ResourceType.ProductPolicy)).toBe(true); + expect(NON_AFFIXABLE_TYPES.has(ResourceType.ApiOperationPolicy)).toBe(true); + expect(NON_AFFIXABLE_TYPES.has(ResourceType.GraphQLResolverPolicy)).toBe(true); + }); + + it('should include wikis and McpServer', () => { + expect(NON_AFFIXABLE_TYPES.has(ResourceType.ApiWiki)).toBe(true); + expect(NON_AFFIXABLE_TYPES.has(ResourceType.ProductWiki)).toBe(true); + expect(NON_AFFIXABLE_TYPES.has(ResourceType.McpServer)).toBe(true); + }); + + it('should include association types', () => { + expect(NON_AFFIXABLE_TYPES.has(ResourceType.ProductApi)).toBe(true); + expect(NON_AFFIXABLE_TYPES.has(ResourceType.ProductGroup)).toBe(true); + expect(NON_AFFIXABLE_TYPES.has(ResourceType.ProductTag)).toBe(true); + expect(NON_AFFIXABLE_TYPES.has(ResourceType.GatewayApi)).toBe(true); + expect(NON_AFFIXABLE_TYPES.has(ResourceType.ApiTag)).toBe(true); + expect(NON_AFFIXABLE_TYPES.has(ResourceType.ApiDiagnostic)).toBe(true); + expect(NON_AFFIXABLE_TYPES.has(ResourceType.ApiOperation)).toBe(true); + expect(NON_AFFIXABLE_TYPES.has(ResourceType.ApiSchema)).toBe(true); + expect(NON_AFFIXABLE_TYPES.has(ResourceType.ApiRelease)).toBe(true); + }); + }); + + // ─── buildEnvMapping ───────────────────────────────────────────────────── + describe('buildEnvMapping', () => { + it('returns undefined for undefined input', () => { + expect(buildEnvMapping(undefined)).toBeUndefined(); + }); + + it('returns undefined for an empty block (no prefix, suffix, apiPathPrefix, or appliesTo)', () => { + expect(buildEnvMapping({})).toBeUndefined(); + }); + + it('returns undefined when prefix and suffix are whitespace-only and no other fields', () => { + expect(buildEnvMapping({ namePrefix: ' ', nameSuffix: ' ' })).toBeUndefined(); + }); + + it('returns a mapping for prefix only', () => { + const m = buildEnvMapping({ namePrefix: 'dev-' }); + expect(m).toBeDefined(); + expect(m!.prefix).toBe('dev-'); + expect(m!.suffix).toBe(''); + }); + + it('returns a mapping for suffix only', () => { + const m = buildEnvMapping({ nameSuffix: '-dev' }); + expect(m).toBeDefined(); + expect(m!.prefix).toBe(''); + expect(m!.suffix).toBe('-dev'); + }); + + it('returns a mapping for both prefix and suffix', () => { + const m = buildEnvMapping({ namePrefix: 'dev-', nameSuffix: '-v2' }); + expect(m).toBeDefined(); + expect(m!.prefix).toBe('dev-'); + expect(m!.suffix).toBe('-v2'); + }); + + it('returns a mapping for apiPathPrefix only (no name affix)', () => { + const m = buildEnvMapping({ apiPathPrefix: '/dev' }); + expect(m).toBeDefined(); + expect(m!.apiPathPrefix).toBe('/dev'); + expect(m!.prefix).toBe(''); + expect(m!.suffix).toBe(''); + }); + + it('trims whitespace from prefix and suffix', () => { + const m = buildEnvMapping({ namePrefix: ' dev- ', nameSuffix: ' -qa ' }); + expect(m!.prefix).toBe('dev-'); + expect(m!.suffix).toBe('-qa'); + }); + + it('uses DEFAULT_APPLIES_TO when appliesTo is omitted', () => { + const m = buildEnvMapping({ namePrefix: 'dev-' }); + expect(m!.appliesTo).toBe(DEFAULT_APPLIES_TO); + }); + + it('uses explicit appliesTo when provided, overriding the default', () => { + const m = buildEnvMapping({ namePrefix: 'dev-', appliesTo: [ResourceType.Api, ResourceType.Gateway] }); + expect(m!.appliesTo.has(ResourceType.Api)).toBe(true); + expect(m!.appliesTo.has(ResourceType.Gateway)).toBe(true); + expect(m!.appliesTo.has(ResourceType.Product)).toBe(false); + }); + + it('accepts an explicit empty appliesTo and still returns a mapping', () => { + const m = buildEnvMapping({ namePrefix: 'dev-', appliesTo: [] }); + expect(m).toBeDefined(); + expect(m!.appliesTo.size).toBe(0); + }); + + it('does not include apiPathPrefix in the result when it is absent', () => { + const m = buildEnvMapping({ namePrefix: 'dev-' }); + expect('apiPathPrefix' in m!).toBe(false); + }); + + it('includes apiPathPrefix in the result when set', () => { + const m = buildEnvMapping({ namePrefix: 'dev-', apiPathPrefix: '/dev' }); + expect(m!.apiPathPrefix).toBe('/dev'); + }); + + it('returns a mapping when only appliesTo is set (no prefix/suffix/apiPathPrefix)', () => { + const m = buildEnvMapping({ appliesTo: [ResourceType.Api] }); + expect(m).toBeDefined(); + expect(m!.appliesTo.has(ResourceType.Api)).toBe(true); + }); + }); + + // ─── buildEnvMappingFromOverrides ──────────────────────────────────────── + describe('buildEnvMappingFromOverrides', () => { + it('returns undefined for undefined overrides', () => { + expect(buildEnvMappingFromOverrides(undefined)).toBeUndefined(); + }); + + it('returns undefined when overrides has no environment field', () => { + const overrides: OverrideConfig = {}; + expect(buildEnvMappingFromOverrides(overrides)).toBeUndefined(); + }); + + it('delegates to buildEnvMapping when environment field is present', () => { + type WithEnv = OverrideConfig & { environment?: EnvironmentOverride }; + const overrides: WithEnv = { environment: { namePrefix: 'dev-' } }; + const m = buildEnvMappingFromOverrides(overrides as OverrideConfig); + expect(m).toBeDefined(); + expect(m!.prefix).toBe('dev-'); + }); + + it('returns undefined when environment block is empty', () => { + type WithEnv = OverrideConfig & { environment?: EnvironmentOverride }; + const overrides: WithEnv = { environment: {} }; + expect(buildEnvMappingFromOverrides(overrides as OverrideConfig)).toBeUndefined(); + }); + }); + + // ─── toDeployedName / toCanonicalName round-trips ──────────────────────── + describe('toDeployedName / toCanonicalName round-trip', () => { + const m = buildEnvMapping({ namePrefix: 'dev-', nameSuffix: '-qa' })!; + + const defaultTypes: ResourceType[] = [ + ResourceType.Api, + ResourceType.Product, + ResourceType.NamedValue, + ResourceType.Backend, + ResourceType.Logger, + ResourceType.PolicyFragment, + ResourceType.VersionSet, + ResourceType.Tag, + ResourceType.Group, + ResourceType.Subscription, + ResourceType.Workspace, + ]; + + it.each(defaultTypes)('round-trips for %s (prefix+suffix)', (type) => { + const canonical = 'my-resource'; + const deployed = toDeployedName(canonical, type, m); + expect(deployed).toBe('dev-my-resource-qa'); + const recovered = toCanonicalName(deployed, type, m); + expect(recovered).toBe(canonical); + }); + + it('types outside default appliesTo are returned unchanged by toDeployedName', () => { + expect(toDeployedName('my-gw', ResourceType.Gateway, m)).toBe('my-gw'); + expect(toDeployedName('my-diag', ResourceType.Diagnostic, m)).toBe('my-diag'); + expect(toDeployedName('my-schema', ResourceType.GlobalSchema, m)).toBe('my-schema'); + }); + + it('types outside default appliesTo are returned unchanged by toCanonicalName', () => { + expect(toCanonicalName('my-gw', ResourceType.Gateway, m)).toBe('my-gw'); + expect(toCanonicalName('dev-my-diag-qa', ResourceType.Diagnostic, m)).toBe('dev-my-diag-qa'); + }); + + it('prefix-only: deployed = prefix + name', () => { + const mp = prefixMapping('dev-'); + expect(toDeployedName('petstore', ResourceType.Api, mp)).toBe('dev-petstore'); + expect(toCanonicalName('dev-petstore', ResourceType.Api, mp)).toBe('petstore'); + }); + + it('suffix-only: deployed = name + suffix', () => { + const ms = suffixMapping('-dev'); + expect(toDeployedName('petstore', ResourceType.Api, ms)).toBe('petstore-dev'); + expect(toCanonicalName('petstore-dev', ResourceType.Api, ms)).toBe('petstore'); + }); + + it('both prefix and suffix: deployed = prefix + name + suffix', () => { + const mb = bothMapping('dev-', '-v2'); + expect(toDeployedName('petstore', ResourceType.Api, mb)).toBe('dev-petstore-v2'); + expect(toCanonicalName('dev-petstore-v2', ResourceType.Api, mb)).toBe('petstore'); + }); + + it('toCanonicalName returns undefined when name does not match prefix', () => { + const mp = prefixMapping('dev-'); + expect(toCanonicalName('prod-foo', ResourceType.Api, mp)).toBeUndefined(); + }); + + it('toCanonicalName returns undefined when suffix does not match', () => { + const ms = suffixMapping('-dev'); + expect(toCanonicalName('petstore-prod', ResourceType.Api, ms)).toBeUndefined(); + }); + + it('toCanonicalName returns undefined when name is too short for prefix+suffix', () => { + const mb = bothMapping('dev-', '-qa'); + expect(toCanonicalName('d', ResourceType.Api, mb)).toBeUndefined(); + }); + }); + + // ─── isInEnvNamespace ──────────────────────────────────────────────────── + describe('isInEnvNamespace', () => { + const m = prefixMapping('dev-'); + + it('returns true when name starts with prefix', () => { + expect(isInEnvNamespace('dev-foo', ResourceType.Api, m)).toBe(true); + }); + + it('returns false when name does not start with prefix', () => { + expect(isInEnvNamespace('prod-foo', ResourceType.Api, m)).toBe(false); + expect(isInEnvNamespace('foo', ResourceType.Api, m)).toBe(false); + }); + + it('returns true for type not in appliesTo (not scoped)', () => { + expect(isInEnvNamespace('anything', ResourceType.Gateway, m)).toBe(true); + expect(isInEnvNamespace('prod-foo', ResourceType.Diagnostic, m)).toBe(true); + }); + + it('handles suffix-only mapping', () => { + const ms = suffixMapping('-dev'); + expect(isInEnvNamespace('foo-dev', ResourceType.Api, ms)).toBe(true); + expect(isInEnvNamespace('foo-prod', ResourceType.Api, ms)).toBe(false); + }); + + it('handles both prefix and suffix', () => { + const mb = bothMapping('dev-', '-v2'); + expect(isInEnvNamespace('dev-foo-v2', ResourceType.Api, mb)).toBe(true); + expect(isInEnvNamespace('dev-foo', ResourceType.Api, mb)).toBe(false); + expect(isInEnvNamespace('foo-v2', ResourceType.Api, mb)).toBe(false); + }); + + it('returns false when name length is less than prefix+suffix combined', () => { + const mb = bothMapping('dev-', '-qa'); + expect(isInEnvNamespace('x', ResourceType.Api, mb)).toBe(false); + }); + }); + + // ─── mapDescriptor ─────────────────────────────────────────────────────── + describe('mapDescriptor', () => { + const m = prefixMapping('dev-'); + + it('affixes top-level type nameParts[0] when type ∈ appliesTo', () => { + const d: ResourceDescriptor = { type: ResourceType.Api, nameParts: ['petstore'] }; + expect(mapDescriptor(d, m)).toEqual({ type: ResourceType.Api, nameParts: ['dev-petstore'] }); + }); + + it('leaves top-level type unchanged when type ∉ appliesTo', () => { + const d: ResourceDescriptor = { type: ResourceType.Diagnostic, nameParts: ['applicationinsights'] }; + expect(mapDescriptor(d, m)).toEqual(d); + }); + + it('ApiOperation: only parent (Api) segment affixed, operation key unchanged', () => { + const d: ResourceDescriptor = { type: ResourceType.ApiOperation, nameParts: ['petstore', 'get-pets'] }; + const result = mapDescriptor(d, m); + expect(result).toEqual({ type: ResourceType.ApiOperation, nameParts: ['dev-petstore', 'get-pets'] }); + }); + + it('ApiPolicy: parent Api name affixed (singleton child)', () => { + const d: ResourceDescriptor = { type: ResourceType.ApiPolicy, nameParts: ['petstore'] }; + const result = mapDescriptor(d, m); + expect(result).toEqual({ type: ResourceType.ApiPolicy, nameParts: ['dev-petstore'] }); + }); + + it('ProductApi: both Product and Api segments affixed (both in default appliesTo)', () => { + const d: ResourceDescriptor = { type: ResourceType.ProductApi, nameParts: ['starter', 'petstore'] }; + const result = mapDescriptor(d, m); + expect(result).toEqual({ type: ResourceType.ProductApi, nameParts: ['dev-starter', 'dev-petstore'] }); + }); + + it('ApiTag: both Api and Tag segments affixed (both in default appliesTo)', () => { + const d: ResourceDescriptor = { type: ResourceType.ApiTag, nameParts: ['petstore', 'version-one'] }; + const result = mapDescriptor(d, m); + expect(result).toEqual({ type: ResourceType.ApiTag, nameParts: ['dev-petstore', 'dev-version-one'] }); + }); + + it('ProductGroup: Product affixed, Group affixed', () => { + const d: ResourceDescriptor = { type: ResourceType.ProductGroup, nameParts: ['starter', 'devs'] }; + const result = mapDescriptor(d, m); + expect(result).toEqual({ type: ResourceType.ProductGroup, nameParts: ['dev-starter', 'dev-devs'] }); + }); + + it('GatewayApi: Gateway NOT in default appliesTo → segment 0 unchanged; Api → affixed', () => { + const d: ResourceDescriptor = { type: ResourceType.GatewayApi, nameParts: ['my-gw', 'petstore'] }; + const result = mapDescriptor(d, m); + expect(result).toEqual({ type: ResourceType.GatewayApi, nameParts: ['my-gw', 'dev-petstore'] }); + }); + + it('ServicePolicy: no nameParts, returned unchanged', () => { + const d: ResourceDescriptor = { type: ResourceType.ServicePolicy, nameParts: [] }; + expect(mapDescriptor(d, m)).toEqual(d); + }); + + it('ProductPolicy: Product segment affixed', () => { + const d: ResourceDescriptor = { type: ResourceType.ProductPolicy, nameParts: ['starter'] }; + expect(mapDescriptor(d, m)).toEqual({ type: ResourceType.ProductPolicy, nameParts: ['dev-starter'] }); + }); + + it('ApiOperationPolicy: Api segment affixed, operation key unchanged', () => { + const d: ResourceDescriptor = { type: ResourceType.ApiOperationPolicy, nameParts: ['petstore', 'get-pets'] }; + const result = mapDescriptor(d, m); + expect(result).toEqual({ type: ResourceType.ApiOperationPolicy, nameParts: ['dev-petstore', 'get-pets'] }); + }); + + it('ApiSchema: Api segment affixed, schema key unchanged', () => { + const d: ResourceDescriptor = { type: ResourceType.ApiSchema, nameParts: ['petstore', 'json'] }; + const result = mapDescriptor(d, m); + expect(result).toEqual({ type: ResourceType.ApiSchema, nameParts: ['dev-petstore', 'json'] }); + }); + + it('workspace-scoped descriptor: workspace field is preserved unchanged', () => { + const d: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: ['petstore'], + workspace: 'my-workspace', + }; + const result = mapDescriptor(d, m); + expect(result.workspace).toBe('my-workspace'); + expect(result.nameParts[0]).toBe('dev-petstore'); + }); + + it('workspace-scoped ApiPolicy: parent Api affixed, workspace preserved', () => { + const d: ResourceDescriptor = { + type: ResourceType.ApiPolicy, + nameParts: ['petstore'], + workspace: 'my-workspace', + }; + const result = mapDescriptor(d, m); + expect(result).toEqual({ type: ResourceType.ApiPolicy, nameParts: ['dev-petstore'], workspace: 'my-workspace' }); + }); + + it('explicit appliesTo with only Product: Api segments not affixed in ProductApi', () => { + const mp = prefixMapping('dev-', [ResourceType.Product]); + const d: ResourceDescriptor = { type: ResourceType.ProductApi, nameParts: ['starter', 'petstore'] }; + const result = mapDescriptor(d, mp); + expect(result).toEqual({ type: ResourceType.ProductApi, nameParts: ['dev-starter', 'petstore'] }); + }); + + it('mapDescriptor does not mutate the original descriptor', () => { + const d: ResourceDescriptor = { type: ResourceType.Api, nameParts: ['petstore'] }; + const original = { ...d, nameParts: [...d.nameParts] }; + mapDescriptor(d, m); + expect(d).toEqual(original); + }); + }); + + // ─── Edge cases ─────────────────────────────────────────────────────────── + describe('edge cases', () => { + it('empty string name round-trips correctly', () => { + const m = prefixMapping('dev-'); + const deployed = toDeployedName('', ResourceType.Api, m); + expect(deployed).toBe('dev-'); + const canonical = toCanonicalName('dev-', ResourceType.Api, m); + expect(canonical).toBe(''); + }); + + it('name that equals only the prefix is an empty canonical', () => { + const m = prefixMapping('dev-'); + expect(toCanonicalName('dev-', ResourceType.Api, m)).toBe(''); + }); + + it('name without the env prefix for a tracked type returns undefined from toCanonicalName', () => { + const m = prefixMapping('dev-'); + expect(toCanonicalName('prod-foo', ResourceType.Api, m)).toBeUndefined(); + }); + + it('suffix check: name with wrong suffix returns undefined', () => { + const m = suffixMapping('-dev'); + expect(toCanonicalName('foo-prod', ResourceType.Api, m)).toBeUndefined(); + }); + + it('prefix+suffix: name matching only prefix returns false from isInEnvNamespace', () => { + const m = bothMapping('dev-', '-qa'); + expect(isInEnvNamespace('dev-foo', ResourceType.Api, m)).toBe(false); + }); + + it('apiPathPrefix-only mapping: names are returned unchanged', () => { + const m = buildEnvMapping({ apiPathPrefix: '/dev' })!; + expect(toDeployedName('petstore', ResourceType.Api, m)).toBe('petstore'); + expect(toCanonicalName('petstore', ResourceType.Api, m)).toBe('petstore'); + expect(isInEnvNamespace('petstore', ResourceType.Api, m)).toBe(true); + }); + }); +}); diff --git a/tests/unit/services/override-merger.test.ts b/tests/unit/services/override-merger.test.ts index 9d23ba0b..13033d6d 100644 --- a/tests/unit/services/override-merger.test.ts +++ b/tests/unit/services/override-merger.test.ts @@ -280,6 +280,51 @@ describe('override-merger', () => { expect(result.properties).toHaveProperty('resourceId', '/subscriptions/new/...'); }); + it('should deep-merge logger credentials (Application Insights instrumentationKey)', () => { + const descriptor: ResourceDescriptor = { + type: ResourceType.Logger, + nameParts: ['appinsights'], + }; + + const json = { + name: 'appinsights', + properties: { + loggerType: 'applicationInsights', + resourceId: '/subscriptions/dev-sub/resourceGroups/dev-rg/providers/microsoft.insights/components/appinsights-dev', + credentials: { + instrumentationKey: '{{old-named-value}}', + }, + isBuffered: true, + }, + }; + + const overrideConfig: OverrideConfig = { + loggers: { + appinsights: { + properties: { + resourceId: '/subscriptions/prod-sub/resourceGroups/prod-rg/providers/microsoft.insights/components/appinsights-prod', + credentials: { + instrumentationKey: '{{application-insights-instrumentation-key}}', + }, + }, + }, + }, + }; + + const result = applyOverrides(descriptor, json, overrideConfig); + const props = result.properties as Record; + // resourceId should be overridden + expect(props.resourceId).toBe( + '/subscriptions/prod-sub/resourceGroups/prod-rg/providers/microsoft.insights/components/appinsights-prod' + ); + // credentials.instrumentationKey should be deep-merged + const creds = props.credentials as Record; + expect(creds.instrumentationKey).toBe('{{application-insights-instrumentation-key}}'); + // other properties should be preserved + expect(props.loggerType).toBe('applicationInsights'); + expect(props.isBuffered).toBe(true); + }); + it('should apply nested API diagnostic overrides', () => { const descriptor: ResourceDescriptor = { type: ResourceType.ApiDiagnostic, diff --git a/tests/unit/services/policy-ref-rewriter.test.ts b/tests/unit/services/policy-ref-rewriter.test.ts new file mode 100644 index 00000000..956131af --- /dev/null +++ b/tests/unit/services/policy-ref-rewriter.test.ts @@ -0,0 +1,164 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { describe, it, expect } from 'vitest'; +import { rewritePolicyRefs } from '../../../src/services/policy-ref-rewriter.js'; +import type { KnownArtifactSets } from '../../../src/services/policy-ref-rewriter.js'; +import type { EnvMapping } from '../../../src/services/env-mapper.js'; +import { ResourceType } from '../../../src/models/resource-types.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function devPrefixMapping(types: ResourceType[]): EnvMapping { + return { + prefix: 'dev-', + suffix: '', + appliesTo: new Set(types), + }; +} + +const ALL_TYPES = [ResourceType.NamedValue, ResourceType.PolicyFragment, ResourceType.Backend]; + +const baseKnown: KnownArtifactSets = { + namedValues: new Set(['myNv', 'apiUrl']), + fragments: new Set(['myFrag', 'authFrag']), + backends: new Set(['myBackend']), +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('rewritePolicyRefs', () => { + it('returns xml unchanged when mapping is undefined', () => { + const xml = '{{myNv}}'; + expect(rewritePolicyRefs(xml, undefined, baseKnown)).toBe(xml); + }); + + it('returns empty string unchanged', () => { + expect(rewritePolicyRefs('', devPrefixMapping(ALL_TYPES), baseKnown)).toBe(''); + }); + + it('rewrites known {{token}} to deployed name', () => { + const xml = '{{myNv}}'; + const result = rewritePolicyRefs(xml, devPrefixMapping([ResourceType.NamedValue]), baseKnown); + expect(result).toBe('{{dev-myNv}}'); + }); + + it('leaves unknown runtime context token untouched', () => { + const xml = ''; + // context.request.headers.foo is not in namedValues — passes through + expect(rewritePolicyRefs(xml, devPrefixMapping(ALL_TYPES), baseKnown)).toBe(xml); + }); + + it('leaves known named value untouched when NamedValue is not in appliesTo', () => { + const xml = '{{myNv}}'; + const mapping = devPrefixMapping([ResourceType.Backend]); // NamedValue excluded + expect(rewritePolicyRefs(xml, mapping, baseKnown)).toBe(xml); + }); + + it('rewrites fragment-id with double quotes', () => { + const xml = ''; + const result = rewritePolicyRefs(xml, devPrefixMapping([ResourceType.PolicyFragment]), baseKnown); + expect(result).toBe(''); + }); + + it('rewrites fragment-id with single quotes', () => { + const xml = ""; + const result = rewritePolicyRefs(xml, devPrefixMapping([ResourceType.PolicyFragment]), baseKnown); + expect(result).toBe(""); + }); + + it('leaves fragment-id unchanged when name is not in known fragments', () => { + const xml = ''; + const result = rewritePolicyRefs(xml, devPrefixMapping([ResourceType.PolicyFragment]), baseKnown); + expect(result).toBe(xml); + }); + + it('rewrites backend-id with single quotes', () => { + const xml = ""; + const result = rewritePolicyRefs(xml, devPrefixMapping([ResourceType.Backend]), baseKnown); + expect(result).toBe(""); + }); + + it('rewrites multiple ref types independently in a single pass', () => { + const xml = + '' + + '{{myNv}}' + + '' + + '' + + ''; + const result = rewritePolicyRefs(xml, devPrefixMapping(ALL_TYPES), baseKnown); + expect(result).toBe( + '' + + '{{dev-myNv}}' + + '' + + '' + + '', + ); + }); + + it('applies a suffix-only mapping', () => { + const suffixMapping: EnvMapping = { + prefix: '', + suffix: '-dev', + appliesTo: new Set(ALL_TYPES), + }; + const xml = '{{myNv}}'; + const result = rewritePolicyRefs(xml, suffixMapping, baseKnown); + expect(result).toBe('{{myNv-dev}}'); + }); + + it('rewrites all three ref types in a realistic APIM policy fixture', () => { + const xml = [ + '', + ' ', + ' ', + ' ', + ' ', + ' {{apiUrl}}', + ' ', + ' ', + ' ', + ' ', + ' ', + '', + ].join('\n'); + + const result = rewritePolicyRefs(xml, devPrefixMapping(ALL_TYPES), baseKnown); + + expect(result).toContain('backend-id="dev-myBackend"'); + expect(result).toContain('{{dev-apiUrl}}'); + expect(result).toContain('fragment-id="dev-authFrag"'); + // Unrelated content is preserved + expect(result).toContain(''); + expect(result).toContain(''); + }); + + it('handles whitespace inside {{ token }} braces', () => { + const xml = '{{ myNv }}'; + const result = rewritePolicyRefs(xml, devPrefixMapping([ResourceType.NamedValue]), baseKnown); + expect(result).toBe('{{dev-myNv}}'); + }); + + it('matches fragment-id regardless of preceding attribute ordering', () => { + const xml = ''; + const result = rewritePolicyRefs(xml, devPrefixMapping([ResourceType.PolicyFragment]), baseKnown); + expect(result).toBe(''); + }); + + it('is idempotent — rewriting twice equals rewriting once', () => { + const xml = + '{{myNv}}' + + '' + + ""; + const mapping = devPrefixMapping(ALL_TYPES); + // After first pass: deployed names like dev-myNv are not in known sets, + // so the second pass finds nothing to rewrite. + const first = rewritePolicyRefs(xml, mapping, baseKnown); + const second = rewritePolicyRefs(first, mapping, baseKnown); + expect(second).toBe(first); + }); +}); diff --git a/tests/unit/services/publish-service.env-mapping.test.ts b/tests/unit/services/publish-service.env-mapping.test.ts new file mode 100644 index 00000000..2e2b78e6 --- /dev/null +++ b/tests/unit/services/publish-service.env-mapping.test.ts @@ -0,0 +1,363 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +/** + * Unit tests for validateAndBuildEnvMapping (env-mapping-validator) + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { validateAndBuildEnvMapping } from '../../../src/services/env-mapping-validator.js'; +import { ResourceType } from '../../../src/models/resource-types.js'; +import type { ResourceDescriptor } from '../../../src/models/types.js'; +import type { OverrideConfig, PublishConfig } from '../../../src/models/config.js'; +import { LogLevel, logger } from '../../../src/lib/logger.js'; +import type { ApimServiceContext } from '../../../src/models/types.js'; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +const testContext: ApimServiceContext = { + subscriptionId: 'sub-1', + resourceGroup: 'rg-1', + serviceName: 'apim-1', + apiVersion: '2024-05-01', + baseUrl: + 'https://management.azure.com/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.ApiManagement/service/apim-1', +}; + +function makeConfig(overrides?: OverrideConfig): PublishConfig { + return { + service: testContext, + sourceDir: '/source', + dryRun: false, + deleteUnmatched: false, + logLevel: LogLevel.INFO, + overrides, + }; +} + +function makeDescriptor(type: ResourceType, name: string): ResourceDescriptor { + return { type, nameParts: [name] }; +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe('validateAndBuildEnvMapping', () => { + let warnSpy: ReturnType; + let infoSpy: ReturnType; + + beforeEach(() => { + warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => undefined); + infoSpy = vi.spyOn(logger, 'info').mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ─── No environment block ──────────────────────────────────────────────── + + it('no overrides → no mapping, no warnings or info', () => { + const config = makeConfig(undefined); + validateAndBuildEnvMapping(undefined, [], config); + expect(config.envMapping).toBeUndefined(); + expect(warnSpy).not.toHaveBeenCalled(); + expect(infoSpy).not.toHaveBeenCalled(); + }); + + it('overrides with no environment block → no mapping, no warnings or info', () => { + const config = makeConfig({ apis: { 'my-api': { properties: {} } } }); + validateAndBuildEnvMapping(config.overrides, [], config); + expect(config.envMapping).toBeUndefined(); + expect(warnSpy).not.toHaveBeenCalled(); + expect(infoSpy).not.toHaveBeenCalled(); + }); + + // ─── Missing name affix → hard error ───────────────────────────────────── + + it('environment block with no namePrefix and no nameSuffix → throws', () => { + const overrides: OverrideConfig = { environment: {} }; + const config = makeConfig(overrides); + expect(() => validateAndBuildEnvMapping(overrides, [], config)).toThrow( + /at least one of 'namePrefix' or 'nameSuffix'/ + ); + expect(config.envMapping).toBeUndefined(); + }); + + it('environment block with only apiPathPrefix → throws (path-only unsupported)', () => { + const overrides: OverrideConfig = { environment: { apiPathPrefix: 'dev/' } }; + const config = makeConfig(overrides); + expect(() => validateAndBuildEnvMapping(overrides, [], config)).toThrow( + /Path-only isolation via 'apiPathPrefix' alone is not supported/ + ); + expect(config.envMapping).toBeUndefined(); + }); + + it('environment block with only whitespace namePrefix → throws', () => { + const overrides: OverrideConfig = { environment: { namePrefix: ' ', nameSuffix: '' } }; + const config = makeConfig(overrides); + expect(() => validateAndBuildEnvMapping(overrides, [], config)).toThrow( + /at least one of 'namePrefix' or 'nameSuffix'/ + ); + }); + + it('environment block with only appliesTo (no affix) → throws', () => { + const overrides: OverrideConfig = { environment: { appliesTo: ['Api'] } }; + const config = makeConfig(overrides); + expect(() => validateAndBuildEnvMapping(overrides, [], config)).toThrow( + /at least one of 'namePrefix' or 'nameSuffix'/ + ); + }); + + // ─── Valid mapping built ───────────────────────────────────────────────── + + it('environment with prefix + default appliesTo → mapping built, one info log', () => { + const overrides: OverrideConfig = { + environment: { namePrefix: 'dev-' }, + }; + const config = makeConfig(overrides); + validateAndBuildEnvMapping(overrides, [], config); + + expect(config.envMapping).toBeDefined(); + expect(config.envMapping!.prefix).toBe('dev-'); + expect(config.envMapping!.suffix).toBe(''); + // Default appliesTo has 11 types + expect(config.envMapping!.appliesTo.size).toBe(11); + + expect(infoSpy).toHaveBeenCalledOnce(); + expect(infoSpy.mock.calls[0]![0]).toContain('prefix="dev-"'); + expect(infoSpy.mock.calls[0]![0]).toContain('suffix=""'); + expect(infoSpy.mock.calls[0]![0]).toContain('applies to 11 types'); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + // ─── Error: non-affixable types ────────────────────────────────────────── + + it('appliesTo contains "ServicePolicy" → throws with clear message', () => { + const overrides: OverrideConfig = { + environment: { + namePrefix: 'dev-', + appliesTo: ['Api', 'ServicePolicy'], + }, + }; + const config = makeConfig(overrides); + expect(() => validateAndBuildEnvMapping(overrides, [], config)).toThrow( + /non-affixable type\(s\): ServicePolicy/ + ); + expect(config.envMapping).toBeUndefined(); + }); + + it('appliesTo contains multiple non-affixable types → lists all offenders', () => { + const overrides: OverrideConfig = { + environment: { + namePrefix: 'dev-', + appliesTo: ['Api', 'ServicePolicy', 'ApiPolicy'], + }, + }; + const config = makeConfig(overrides); + expect(() => validateAndBuildEnvMapping(overrides, [], config)).toThrow( + /ServicePolicy.*ApiPolicy|ApiPolicy.*ServicePolicy/ + ); + }); + + // ─── Error: unknown type names ─────────────────────────────────────────── + + it('appliesTo contains "NotARealType" → throws listing unknown type', () => { + const overrides: OverrideConfig = { + environment: { + namePrefix: 'dev-', + appliesTo: ['Api', 'NotARealType'], + }, + }; + const config = makeConfig(overrides); + expect(() => validateAndBuildEnvMapping(overrides, [], config)).toThrow( + /unknown resource type\(s\): NotARealType/ + ); + expect(config.envMapping).toBeUndefined(); + }); + + it('appliesTo contains multiple unknown types → lists all in error', () => { + const overrides: OverrideConfig = { + environment: { + namePrefix: 'dev-', + appliesTo: ['Foo', 'Bar'], + }, + }; + const config = makeConfig(overrides); + expect(() => validateAndBuildEnvMapping(overrides, [], config)).toThrow(/Foo.*Bar|Bar.*Foo/); + }); + + // ─── Warn: invalid affix chars ─────────────────────────────────────────── + + it('namePrefix with invalid chars → warning logged, mapping still built', () => { + const overrides: OverrideConfig = { + environment: { namePrefix: 'dev@' }, + }; + const config = makeConfig(overrides); + validateAndBuildEnvMapping(overrides, [], config); + + expect(warnSpy).toHaveBeenCalledOnce(); + expect(warnSpy.mock.calls[0]![0]).toContain('namePrefix'); + expect(warnSpy.mock.calls[0]![0]).toContain('dev@'); + expect(config.envMapping).toBeDefined(); + expect(infoSpy).toHaveBeenCalledOnce(); + }); + + it('nameSuffix with invalid chars → warning logged', () => { + const overrides: OverrideConfig = { + environment: { nameSuffix: '-qa!' }, + }; + const config = makeConfig(overrides); + validateAndBuildEnvMapping(overrides, [], config); + + expect(warnSpy).toHaveBeenCalledOnce(); + expect(warnSpy.mock.calls[0]![0]).toContain('nameSuffix'); + expect(warnSpy.mock.calls[0]![0]).toContain('-qa!'); + }); + + it('valid prefix/suffix chars → no warning', () => { + const overrides: OverrideConfig = { + environment: { namePrefix: 'dev-', nameSuffix: '-v2' }, + }; + const config = makeConfig(overrides); + validateAndBuildEnvMapping(overrides, [], config); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + // ─── Warn: apiPathPrefix with no Api descriptors ───────────────────────── + + it('apiPathPrefix set but zero Api descriptors → warning logged', () => { + const overrides: OverrideConfig = { + environment: { namePrefix: 'dev-', apiPathPrefix: 'dev/' }, + }; + const nonApiDescriptors: ResourceDescriptor[] = [ + makeDescriptor(ResourceType.Product, 'my-product'), + ]; + const config = makeConfig(overrides); + validateAndBuildEnvMapping(overrides, nonApiDescriptors, config); + + expect(warnSpy).toHaveBeenCalledOnce(); + expect(warnSpy.mock.calls[0]![0]).toContain('apiPathPrefix'); + expect(warnSpy.mock.calls[0]![0]).toContain('no Api resources'); + }); + + it('apiPathPrefix set and Api descriptors present → no apiPathPrefix warning', () => { + const overrides: OverrideConfig = { + environment: { namePrefix: 'dev-', apiPathPrefix: 'dev/' }, + }; + const descriptors: ResourceDescriptor[] = [makeDescriptor(ResourceType.Api, 'petstore')]; + const config = makeConfig(overrides); + validateAndBuildEnvMapping(overrides, descriptors, config); + + const apiWarn = warnSpy.mock.calls.find((c) => + String(c[0]).includes('apiPathPrefix') + ); + expect(apiWarn).toBeUndefined(); + }); + + // ─── Warn: affixed name exceeds APIM 80-char limit ─────────────────────── + + it('affixed name exceeds 80 chars → warning with name, length, and type', () => { + const overrides: OverrideConfig = { + environment: { namePrefix: 'production-us-east-', nameSuffix: '-v2-stable' }, + }; + // canonical is 60 chars → 60 + 19 (prefix) + 10 (suffix) = 89 + const longCanonical = 'enterprise-billing-reconciliation-service-consolidated-api-x'; + expect(longCanonical.length).toBe(60); + const descriptors: ResourceDescriptor[] = [makeDescriptor(ResourceType.Api, longCanonical)]; + const config = makeConfig(overrides); + validateAndBuildEnvMapping(overrides, descriptors, config); + + const lengthWarn = warnSpy.mock.calls.find((c) => + String(c[0]).includes('exceeding APIM\'s 80-character') + ); + expect(lengthWarn).toBeDefined(); + expect(String(lengthWarn![0])).toContain(`production-us-east-${longCanonical}-v2-stable`); + expect(String(lengthWarn![0])).toContain('89 characters'); + expect(String(lengthWarn![0])).toContain('Api'); + }); + + it('affixed name exactly 80 chars → no length warning', () => { + const overrides: OverrideConfig = { + environment: { namePrefix: 'dev-' }, // 4 chars + }; + const canonical = 'a'.repeat(76); // 4 + 76 = 80 + const descriptors: ResourceDescriptor[] = [makeDescriptor(ResourceType.Api, canonical)]; + const config = makeConfig(overrides); + validateAndBuildEnvMapping(overrides, descriptors, config); + + const lengthWarn = warnSpy.mock.calls.find((c) => + String(c[0]).includes('80-character') + ); + expect(lengthWarn).toBeUndefined(); + }); + + it('affixed name would exceed 80 chars but type not in appliesTo → no warning', () => { + const overrides: OverrideConfig = { + environment: { + namePrefix: 'production-us-east-', + nameSuffix: '-v2-stable', + appliesTo: ['Api'], // Product not in appliesTo + }, + }; + const longCanonical = 'a'.repeat(60); + const descriptors: ResourceDescriptor[] = [ + makeDescriptor(ResourceType.Product, longCanonical), + ]; + const config = makeConfig(overrides); + validateAndBuildEnvMapping(overrides, descriptors, config); + + const lengthWarn = warnSpy.mock.calls.find((c) => + String(c[0]).includes('80-character') + ); + expect(lengthWarn).toBeUndefined(); + }); + + // ─── Warn: override entry references unknown resource ──────────────────── + + it('apis override references name not in artifact descriptors → warning logged', () => { + const overrides: OverrideConfig = { + environment: { namePrefix: 'dev-' }, + apis: { 'old-api-name': { properties: {} } }, + }; + const descriptors: ResourceDescriptor[] = [makeDescriptor(ResourceType.Api, 'current-api')]; + const config = makeConfig(overrides); + validateAndBuildEnvMapping(overrides, descriptors, config); + + const staleWarn = warnSpy.mock.calls.find((c) => String(c[0]).includes('old-api-name')); + expect(staleWarn).toBeDefined(); + expect(String(staleWarn![0])).toContain('overrides.apis'); + }); + + it('apis override matches existing descriptor → no stale-override warning', () => { + const overrides: OverrideConfig = { + environment: { namePrefix: 'dev-' }, + apis: { petstore: { properties: { displayName: 'Pet Store Dev' } } }, + }; + const descriptors: ResourceDescriptor[] = [makeDescriptor(ResourceType.Api, 'petstore')]; + const config = makeConfig(overrides); + validateAndBuildEnvMapping(overrides, descriptors, config); + + const staleWarn = warnSpy.mock.calls.find((c) => String(c[0]).includes('stale')); + expect(staleWarn).toBeUndefined(); + }); + + // ─── envMapping stored on config ───────────────────────────────────────── + + it('populates config.envMapping with the built mapping', () => { + const overrides: OverrideConfig = { + environment: { + namePrefix: 'qa-', + nameSuffix: '-v1', + appliesTo: ['Api', 'Product'], + }, + }; + const config = makeConfig(overrides); + validateAndBuildEnvMapping(overrides, [], config); + + expect(config.envMapping).toBeDefined(); + expect(config.envMapping!.prefix).toBe('qa-'); + expect(config.envMapping!.suffix).toBe('-v1'); + expect(config.envMapping!.appliesTo.has(ResourceType.Api)).toBe(true); + expect(config.envMapping!.appliesTo.has(ResourceType.Product)).toBe(true); + expect(config.envMapping!.appliesTo.size).toBe(2); + }); +}); diff --git a/tests/unit/services/publish-service.test.ts b/tests/unit/services/publish-service.test.ts index 2f842263..013322f8 100644 --- a/tests/unit/services/publish-service.test.ts +++ b/tests/unit/services/publish-service.test.ts @@ -466,6 +466,53 @@ describe('publish-service', () => { expect(computeDeleteActions).not.toHaveBeenCalled(); }); + it('incremental mode: lists FULL artifact set for env-mapping validation and known-artifact sets', async () => { + // Regression: prior to this fix, publish-service passed the changed + // subset (from computeGitDiff) to both validateAndBuildEnvMapping and + // buildKnownArtifactSets. That silently broke policy XML ref rewriting + // for any NamedValue / PolicyFragment / Backend whose file did not + // change in the current commit, and produced false stale-override + // warnings on every unchanged override entry. + const client = createMockClient(); + const fullArtifacts: ResourceDescriptor[] = [ + { type: ResourceType.NamedValue, nameParts: ['nv-unchanged'] }, + { type: ResourceType.NamedValue, nameParts: ['nv-changed'] }, + { type: ResourceType.Api, nameParts: ['api1'] }, + ]; + const store = createMockStore(fullArtifacts); + + vi.mocked(computeGitDiff).mockResolvedValue({ + changedDescriptors: [ + { type: ResourceType.NamedValue, nameParts: ['nv-changed'] }, + ], + deletedDescriptors: [], + }); + + const config: PublishConfig = { + service: testContext, + sourceDir: '/source', + dryRun: false, + deleteUnmatched: false, + commitId: 'abc123', + logLevel: LogLevel.INFO, + overrides: { + environment: { namePrefix: 'dev-' }, + }, + }; + + await runPublish(client, store, config); + + // In incremental mode, publish-service must call store.listResources to + // build the full artifact set for env-mapping validation and policy-ref + // rewriting — not just rely on the git-diff subset. + expect(store.listResources).toHaveBeenCalledWith('/source'); + // envMapping must be populated on config after validation + expect(config.envMapping).toBeDefined(); + // knownArtifactSets must contain the FULL set of named values, not just changed + expect(config.knownArtifactSets?.namedValues.has('nv-unchanged')).toBe(true); + expect(config.knownArtifactSets?.namedValues.has('nv-changed')).toBe(true); + }); + it('should pass commit-scoped deleted descriptors to dry-run report', async () => { const client = createMockClient(); const store = createMockStore([]); diff --git a/tests/unit/services/resource-publisher.env-mapping.test.ts b/tests/unit/services/resource-publisher.env-mapping.test.ts new file mode 100644 index 00000000..2536734e --- /dev/null +++ b/tests/unit/services/resource-publisher.env-mapping.test.ts @@ -0,0 +1,436 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +/** + * Integration tests for resource-publisher env-mapping behavior. + * Verifies that EnvMapping is applied at every PUT boundary. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { publishResource } from '../../../src/services/resource-publisher.js'; +import { ResourceType } from '../../../src/models/resource-types.js'; +import type { ApimServiceContext, ResourceDescriptor } from '../../../src/models/types.js'; +import type { PublishConfig, KnownArtifactSets } from '../../../src/models/config.js'; +import type { EnvMapping } from '../../../src/services/env-mapper.js'; +import { DEFAULT_APPLIES_TO } from '../../../src/services/env-mapper.js'; +import { LogLevel } from '../../../src/lib/logger.js'; + +// Mock keyvault-checker +vi.mock('../../../src/services/keyvault-checker.js', () => ({ + checkKeyVaultSecretAccess: vi.fn().mockResolvedValue(undefined), + KeyVaultAccessError: class KeyVaultAccessError extends Error { + constructor(message: string) { + super(message); + this.name = 'KeyVaultAccessError'; + } + }, +})); + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function createMockClient() { + return { + listResources: async function* () {}, + getResource: vi.fn(), + putResource: vi.fn().mockResolvedValue(undefined), + patchResource: vi.fn().mockResolvedValue(undefined), + deleteResource: vi.fn(), + listApiRevisions: async function* () {}, + getApiSpecification: vi.fn(), + validatePreFlight: vi.fn().mockResolvedValue(undefined), + }; +} + +function createMockStore() { + return { + writeResource: vi.fn(), + writeContent: vi.fn(), + writeAssociation: vi.fn(), + readResource: vi.fn().mockResolvedValue(null), + readContent: vi.fn().mockResolvedValue(undefined), + readAssociation: vi.fn().mockResolvedValue([]), + listResources: vi.fn().mockResolvedValue([]), + deleteResource: vi.fn(), + }; +} + +const testContext: ApimServiceContext = { + subscriptionId: 'sub-1', + resourceGroup: 'rg-1', + serviceName: 'apim-1', + apiVersion: '2024-05-01', + baseUrl: + 'https://management.azure.com/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.ApiManagement/service/apim-1', +}; + +const baseConfig: PublishConfig = { + service: testContext, + sourceDir: '/source', + dryRun: false, + deleteUnmatched: false, + logLevel: LogLevel.INFO, +}; + +const DEV_MAPPING: EnvMapping = { + prefix: 'dev-', + suffix: '', + appliesTo: DEFAULT_APPLIES_TO, +}; + +const EMPTY_KNOWN: KnownArtifactSets = { + namedValues: new Set(), + fragments: new Set(), + backends: new Set(), +}; + +function makeConfig(overrides?: Partial): PublishConfig { + return { ...baseConfig, ...overrides }; +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe('resource-publisher env-mapping', () => { + let client: ReturnType; + let store: ReturnType; + + beforeEach(() => { + client = createMockClient(); + store = createMockStore(); + }); + + describe('no envMapping — regression', () => { + it('passes descriptor unchanged when no envMapping', async () => { + store.readResource.mockResolvedValue({ name: 'petstore', properties: { displayName: 'Petstore' } }); + const descriptor: ResourceDescriptor = { type: ResourceType.Api, nameParts: ['petstore'] }; + await publishResource(client, store, testContext, descriptor, makeConfig()); + const [, putDescriptor] = client.putResource.mock.calls[0] as [unknown, ResourceDescriptor, unknown]; + expect(putDescriptor.nameParts[0]).toBe('petstore'); + }); + }); + + describe('Api PUT', () => { + it('affixes descriptor name with prefix', async () => { + store.readResource.mockResolvedValue({ name: 'petstore', properties: { displayName: 'Petstore', path: 'petstore' } }); + const descriptor: ResourceDescriptor = { type: ResourceType.Api, nameParts: ['petstore'] }; + const config = makeConfig({ envMapping: DEV_MAPPING, knownArtifactSets: EMPTY_KNOWN }); + await publishResource(client, store, testContext, descriptor, config); + const [, putDescriptor] = client.putResource.mock.calls[0] as [unknown, ResourceDescriptor, unknown]; + expect(putDescriptor.nameParts[0]).toBe('dev-petstore'); + }); + }); + + describe('Api path prefix', () => { + it('prepends apiPathPrefix to properties.path when no explicit path override', async () => { + store.readResource.mockResolvedValue({ name: 'petstore', properties: { path: 'petstore', displayName: 'Petstore' } }); + const descriptor: ResourceDescriptor = { type: ResourceType.Api, nameParts: ['petstore'] }; + const mappingWithPath: EnvMapping = { ...DEV_MAPPING, apiPathPrefix: 'dev/' }; + const config = makeConfig({ envMapping: mappingWithPath, knownArtifactSets: EMPTY_KNOWN }); + await publishResource(client, store, testContext, descriptor, config); + const [, , payload] = client.putResource.mock.calls[0] as [unknown, unknown, Record]; + expect((payload.properties as Record).path).toBe('dev/petstore'); + }); + + it('does NOT prepend apiPathPrefix when explicit path override is present', async () => { + store.readResource.mockResolvedValue({ name: 'petstore', properties: { path: 'petstore', displayName: 'Petstore' } }); + const descriptor: ResourceDescriptor = { type: ResourceType.Api, nameParts: ['petstore'] }; + const mappingWithPath: EnvMapping = { ...DEV_MAPPING, apiPathPrefix: 'dev/' }; + const config = makeConfig({ + envMapping: mappingWithPath, + knownArtifactSets: EMPTY_KNOWN, + overrides: { apis: { petstore: { properties: { path: 'my-explicit-path' } } } }, + }); + await publishResource(client, store, testContext, descriptor, config); + const [, , payload] = client.putResource.mock.calls[0] as [unknown, unknown, Record]; + // Override sets path to 'my-explicit-path'; no prefix applied + expect((payload.properties as Record).path).toBe('my-explicit-path'); + }); + }); + + describe('ApiOperation PUT', () => { + it('affixes parent Api name but leaves operation key unchanged', async () => { + store.readResource.mockResolvedValue({ name: 'get-pets', properties: { displayName: 'List Pets', method: 'GET', urlTemplate: '/pets', description: '' } }); + const descriptor: ResourceDescriptor = { + type: ResourceType.ApiOperation, + nameParts: ['petstore', 'get-pets'], + }; + const config = makeConfig({ envMapping: DEV_MAPPING, knownArtifactSets: EMPTY_KNOWN }); + await publishResource(client, store, testContext, descriptor, config); + const [, putDescriptor] = client.putResource.mock.calls[0] as [unknown, ResourceDescriptor, unknown]; + expect(putDescriptor.nameParts[0]).toBe('dev-petstore'); // parent affixed + expect(putDescriptor.nameParts[1]).toBe('get-pets'); // operation key unchanged + }); + }); + + describe('ProductApi association', () => { + it('affixes both product and api segments', async () => { + store.readAssociation.mockResolvedValue([{ name: 'petstore' }]); + const descriptor: ResourceDescriptor = { + type: ResourceType.ProductApi, + nameParts: ['starter'], + }; + const config = makeConfig({ envMapping: DEV_MAPPING, knownArtifactSets: EMPTY_KNOWN }); + await publishResource(client, store, testContext, descriptor, config); + const [, putDescriptor] = client.putResource.mock.calls[0] as [unknown, ResourceDescriptor, unknown]; + expect(putDescriptor.nameParts[0]).toBe('dev-starter'); // product affixed + expect(putDescriptor.nameParts[1]).toBe('dev-petstore'); // api affixed + }); + }); + + describe('Subscription scope', () => { + it('affixes api name in /apis/ scope', async () => { + const armPrefix = + '/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.ApiManagement/service/apim-1'; + store.readResource.mockResolvedValue({ + name: 'my-sub', + properties: { + scope: `${armPrefix}/apis/petstore-api`, + displayName: 'My Sub', + }, + }); + const descriptor: ResourceDescriptor = { + type: ResourceType.Subscription, + nameParts: ['my-sub'], + }; + const config = makeConfig({ envMapping: DEV_MAPPING, knownArtifactSets: EMPTY_KNOWN }); + await publishResource(client, store, testContext, descriptor, config); + const [, , payload] = client.putResource.mock.calls[0] as [unknown, unknown, Record]; + expect((payload.properties as Record).scope).toBe('/apis/dev-petstore-api'); + }); + + it('affixes product name in /products/ scope', async () => { + const armPrefix = + '/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.ApiManagement/service/apim-1'; + store.readResource.mockResolvedValue({ + name: 'my-sub', + properties: { + scope: `${armPrefix}/products/starter`, + displayName: 'My Sub', + }, + }); + const descriptor: ResourceDescriptor = { + type: ResourceType.Subscription, + nameParts: ['my-sub'], + }; + const config = makeConfig({ envMapping: DEV_MAPPING, knownArtifactSets: EMPTY_KNOWN }); + await publishResource(client, store, testContext, descriptor, config); + const [, , payload] = client.putResource.mock.calls[0] as [unknown, unknown, Record]; + expect((payload.properties as Record).scope).toBe('/products/dev-starter'); + }); + }); + + describe('ApiRelease apiId', () => { + it('affixes api name in apiId, preserving ;rev=N suffix', async () => { + const srcArmPrefix = + '/subscriptions/src-sub/resourceGroups/src-rg/providers/Microsoft.ApiManagement/service/src-apim'; + store.readResource.mockResolvedValue({ + name: 'rel1', + properties: { + apiId: `${srcArmPrefix}/apis/petstore-api;rev=2`, + notes: 'v2 release', + }, + }); + const descriptor: ResourceDescriptor = { + type: ResourceType.ApiRelease, + nameParts: ['petstore-api', 'rel1'], + }; + const config = makeConfig({ envMapping: DEV_MAPPING, knownArtifactSets: EMPTY_KNOWN }); + await publishResource(client, store, testContext, descriptor, config); + const [, , payload] = client.putResource.mock.calls[0] as [unknown, unknown, Record]; + const apiId = (payload.properties as Record).apiId as string; + expect(apiId).toContain('/apis/dev-petstore-api;rev=2'); + }); + }); + + describe('API revision sourceApiId', () => { + it('affixes baseApiName in sourceApiId', async () => { + store.readResource.mockResolvedValue({ + name: 'petstore;rev=2', + properties: { displayName: 'Petstore', path: 'petstore', apiRevision: '2' }, + }); + const descriptor: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: ['petstore;rev=2'], + }; + const config = makeConfig({ envMapping: DEV_MAPPING, knownArtifactSets: EMPTY_KNOWN }); + await publishResource(client, store, testContext, descriptor, config); + const [, , payload] = client.putResource.mock.calls[0] as [unknown, unknown, Record]; + const sourceApiId = (payload.properties as Record).sourceApiId as string; + expect(sourceApiId).toContain('/apis/dev-petstore'); + }); + }); + + describe('NamedValue displayName', () => { + it('affixes displayName when no explicit override', async () => { + store.readResource.mockResolvedValue({ + name: 'myNv', + properties: { displayName: 'myNv', value: 'secret', secret: false }, + }); + const descriptor: ResourceDescriptor = { + type: ResourceType.NamedValue, + nameParts: ['myNv'], + }; + const config = makeConfig({ envMapping: DEV_MAPPING, knownArtifactSets: EMPTY_KNOWN }); + await publishResource(client, store, testContext, descriptor, config); + const [, , payload] = client.putResource.mock.calls[0] as [unknown, unknown, Record]; + expect((payload.properties as Record).displayName).toBe('dev-myNv'); + }); + + it('does NOT re-affix displayName when user override sets it explicitly', async () => { + store.readResource.mockResolvedValue({ + name: 'myNv', + properties: { displayName: 'myNv', value: 'secret', secret: false }, + }); + const descriptor: ResourceDescriptor = { + type: ResourceType.NamedValue, + nameParts: ['myNv'], + }; + const config = makeConfig({ + envMapping: DEV_MAPPING, + knownArtifactSets: EMPTY_KNOWN, + overrides: { + namedValues: { myNv: { properties: { displayName: 'explicit-display-name' } } }, + }, + }); + await publishResource(client, store, testContext, descriptor, config); + const [, , payload] = client.putResource.mock.calls[0] as [unknown, unknown, Record]; + expect((payload.properties as Record).displayName).toBe('explicit-display-name'); + }); + }); + + describe('Logger credentials {{ref}} rewrite', () => { + it('rewrites {{canonicalNvName}} to {{dev-canonicalNvName}} when envMapping applies', async () => { + // Logger has {{myNv}} credential; NV artifact has displayName matching resource name + store.readResource.mockImplementation(async (_dir: string, d: ResourceDescriptor) => { + if (d.type === ResourceType.Logger) { + return { + name: 'my-logger', + properties: { + loggerType: 'applicationInsights', + credentials: { instrumentationKey: '{{myNv}}' }, + }, + }; + } + if (d.type === ResourceType.NamedValue && d.nameParts[0] === 'myNv') { + return { + name: 'myNv', + properties: { displayName: 'myNv', value: 'secret', secret: false }, + }; + } + return null; + }); + const descriptor: ResourceDescriptor = { + type: ResourceType.Logger, + nameParts: ['my-logger'], + }; + const config = makeConfig({ envMapping: DEV_MAPPING, knownArtifactSets: EMPTY_KNOWN }); + await publishResource(client, store, testContext, descriptor, config); + const [, , payload] = client.putResource.mock.calls[0] as [unknown, unknown, Record]; + const creds = (payload.properties as Record).credentials as Record; + expect(creds.instrumentationKey).toBe('{{dev-myNv}}'); + }); + }); + + describe('Policy XML rewrite', () => { + it('rewrites {{nvName}}, fragment-id, backend-id in policy content', async () => { + const xml = + '' + + '{{myNv}}' + + '' + + '' + + ''; + store.readContent.mockResolvedValue({ content: xml }); + const descriptor: ResourceDescriptor = { + type: ResourceType.ApiPolicy, + nameParts: ['petstore'], + }; + const known: KnownArtifactSets = { + namedValues: new Set(['myNv']), + fragments: new Set(['myFrag']), + backends: new Set(['myBackend']), + }; + const config = makeConfig({ envMapping: DEV_MAPPING, knownArtifactSets: known }); + await publishResource(client, store, testContext, descriptor, config); + const [, , payload] = client.putResource.mock.calls[0] as [unknown, unknown, Record]; + const value = (payload.properties as Record).value as string; + expect(value).toContain('{{dev-myNv}}'); + expect(value).toContain('fragment-id="dev-myFrag"'); + expect(value).toContain('backend-id="dev-myBackend"'); + }); + }); + + describe('PolicyFragment content rewrite', () => { + it('rewrites refs inside fragment value', async () => { + store.readResource.mockResolvedValue({ + name: 'myFrag', + properties: { + value: '', + description: 'My fragment', + format: 'rawxml', + }, + }); + const descriptor: ResourceDescriptor = { + type: ResourceType.PolicyFragment, + nameParts: ['myFrag'], + }; + const known: KnownArtifactSets = { + namedValues: new Set(), + fragments: new Set(), + backends: new Set(['myBackend']), + }; + const config = makeConfig({ envMapping: DEV_MAPPING, knownArtifactSets: known }); + await publishResource(client, store, testContext, descriptor, config); + const [, , payload] = client.putResource.mock.calls[0] as [unknown, unknown, Record]; + const value = (payload.properties as Record).value as string; + expect(value).toContain('backend-id="dev-myBackend"'); + }); + }); + + describe('Type not in appliesTo', () => { + it('passes Diagnostic descriptor unchanged when appliesTo is default (Diagnostic not included)', async () => { + store.readResource.mockResolvedValue({ + name: 'applicationinsights', + properties: { loggerId: '/loggers/my-logger' }, + }); + const descriptor: ResourceDescriptor = { + type: ResourceType.Diagnostic, + nameParts: ['applicationinsights'], + }; + // DEFAULT_APPLIES_TO does not include Diagnostic + const config = makeConfig({ envMapping: DEV_MAPPING, knownArtifactSets: EMPTY_KNOWN }); + await publishResource(client, store, testContext, descriptor, config); + const [, putDescriptor] = client.putResource.mock.calls[0] as [unknown, ResourceDescriptor, unknown]; + expect(putDescriptor.nameParts[0]).toBe('applicationinsights'); // unchanged + }); + }); + + describe('McpTool operationId', () => { + it('affixes api name segment in operationId, leaves operation name unchanged', async () => { + const srcArmPrefix = + '/subscriptions/src-sub/resourceGroups/src-rg/providers/Microsoft.ApiManagement/service/src-apim'; + store.readResource.mockResolvedValue({ + name: 'mcp-api', + properties: { + displayName: 'MCP API', + path: 'mcp', + type: 'http', + mcpTools: [ + { + name: 'listPets', + operationId: `${srcArmPrefix}/apis/petstore/operations/list-pets`, + }, + ], + }, + }); + const descriptor: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: ['mcp-api'], + }; + const config = makeConfig({ envMapping: DEV_MAPPING, knownArtifactSets: EMPTY_KNOWN }); + await publishResource(client, store, testContext, descriptor, config); + const [, , payload] = client.putResource.mock.calls[0] as [unknown, unknown, Record]; + const tools = (payload.properties as Record).mcpTools as Array>; + const opId = tools[0].operationId as string; + expect(opId).toContain('/apis/dev-petstore/'); + expect(opId).toContain('/operations/list-pets'); // operation unchanged + }); + }); +}); diff --git a/tests/unit/templates/configs/config-templates.test.ts b/tests/unit/templates/configs/config-templates.test.ts index 7d85014d..e25adbc1 100644 --- a/tests/unit/templates/configs/config-templates.test.ts +++ b/tests/unit/templates/configs/config-templates.test.ts @@ -144,6 +144,8 @@ describe('configs/override-config', () => { expect(config).toContain('# - name: appinsights-logger'); expect(config).toContain('# properties:'); expect(config).toContain('# resourceId:'); + expect(config).toContain('# credentials:'); + expect(config).toContain('# instrumentationKey:'); }); it('should not have any uncommented configuration by default', () => { @@ -163,5 +165,31 @@ describe('configs/override-config', () => { expect(config).not.toMatch(/\{\{[^}]+\}\}/); expect(config).toContain('staging-api-key-value'); }); + + it('should include commented environment block for shared APIM', () => { + const config = generateOverrideConfig('dev'); + expect(config).toContain('# environment:'); + expect(config).toContain('# namePrefix:'); + expect(config).toContain('# apiPathPrefix:'); + }); + + it('should substitute ENVIRONMENT token inside commented environment block', () => { + const config = generateOverrideConfig('dev'); + expect(config).toContain('# environment:'); + expect(config).toContain('"dev-"'); + expect(config).toContain('"dev/"'); + }); + + it('should reference multi-environment-shared-apim guide in environment block', () => { + const config = generateOverrideConfig('dev'); + expect(config).toContain('multi-environment-shared-apim.md'); + }); + + it('should produce valid YAML even with commented environment block', () => { + const config = generateOverrideConfig('dev'); + // All non-empty, non-comment lines must be absent for a comment-only file + const uncommentedLines = config.split('\n').filter((line) => line.trim() && !line.trim().startsWith('#')); + expect(uncommentedLines).toHaveLength(0); + }); }); });