From b13430f22799e45398593059a049185582e53fe2 Mon Sep 17 00:00:00 2001 From: Matthew Johnson <10487726+centreboard@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:09:39 +0100 Subject: [PATCH 1/4] feat: implement workspace-scoped overrides and add unit tests Previously the CLI applied overrides to the workspace container itself (the properties on the workspace entry), but silently ignored all nested child overrides (apis, backends, loggers, etc. within the workspace) Compatibility note: workspace-scoped resources that previously picked up a same-named top-level override will no longer do so. The new tests make that isolation intentional, but existing configs relying on the old behavior may publish different values. --- docs/guides/environment-overrides.md | 2 - src/services/override-merger.ts | 43 +++- tests/unit/services/override-merger.test.ts | 225 ++++++++++++++++++++ 3 files changed, 257 insertions(+), 13 deletions(-) diff --git a/docs/guides/environment-overrides.md b/docs/guides/environment-overrides.md index 30ebcba7..2dcfcc04 100644 --- a/docs/guides/environment-overrides.md +++ b/docs/guides/environment-overrides.md @@ -416,8 +416,6 @@ workspaces: The supported workspace child sections are: `apis`, `backends`, `diagnostics`, `groups`, `loggers`, `namedValues`, `policyFragments`, `products`, `subscriptions`, `tags`, and `versionSets`. -> ⚠️ **Known limitation — tracked in [#118](https://github.com/Azure/apiops-cli/issues/118):** workspace child overrides are *parsed* (the YAML above is accepted with no errors) but are **not yet applied at publish time**. Until #118 is fixed, only the workspace container's own `properties` are honored for workspace-scoped resources. Authoring overrides in this nested shape today is safe and forward-compatible — they will start taking effect automatically once the merger is updated. - ## Override rules ### Names must be consistent diff --git a/src/services/override-merger.ts b/src/services/override-merger.ts index e6d3a012..ae283254 100644 --- a/src/services/override-merger.ts +++ b/src/services/override-merger.ts @@ -91,7 +91,7 @@ export function applyOverrides( // Try direct override lookup first const directSection = OVERRIDE_SECTION_MAP[descriptor.type]; if (directSection) { - const section = overrides[directSection]; + const section = resolveSection(overrides, directSection, descriptor.workspace); if (!section) return { ...json }; return applyFromSection(descriptor, json, section); } @@ -99,18 +99,45 @@ export function applyOverrides( // Try nested child override lookup const childMapping = CHILD_OVERRIDE_MAP[descriptor.type]; if (childMapping) { - return applyNestedOverride(descriptor, json, overrides, childMapping); + const parentSection = resolveSection(overrides, childMapping.parentSection, descriptor.workspace); + if (!parentSection) return { ...json }; + return applyNestedOverride(descriptor, json, parentSection, childMapping); } // Try grandchild (3-level) override lookup const grandchildMapping = GRANDCHILD_OVERRIDE_MAP[descriptor.type]; if (grandchildMapping) { - return applyGrandchildOverride(descriptor, json, overrides, grandchildMapping); + const parentSection = resolveSection(overrides, grandchildMapping.parentSection, descriptor.workspace); + if (!parentSection) return { ...json }; + return applyGrandchildOverride(descriptor, json, parentSection, grandchildMapping); } return { ...json }; } +/** + * Resolve the override section to search for a given section key, honoring workspace scoping. + * Workspace-scoped resources are strictly isolated: they only read from + * `overrides.workspaces[workspace].children[sectionKey]`, never the top-level section. + */ +function resolveSection( + overrides: OverrideConfig, + sectionKey: OverrideSectionKey, + workspace: string | undefined +): OverrideSection | undefined { + if (!workspace) { + return overrides[sectionKey]; + } + + const workspacesSection = overrides.workspaces; + if (!workspacesSection) return undefined; + + const workspaceEntry = findEntryByName(workspacesSection, workspace); + if (!workspaceEntry?.children) return undefined; + + return workspaceEntry.children[sectionKey]; +} + /** * Apply override from a direct section match. * For singleton resources (e.g., ServicePolicy with nameParts: []), @@ -172,12 +199,9 @@ function applyFromSection( function applyNestedOverride( descriptor: ResourceDescriptor, json: Record, - overrides: OverrideConfig, + parentSection: OverrideSection, mapping: { parentSection: OverrideSectionKey; childKey: string } ): Record { - const parentSection = overrides[mapping.parentSection]; - if (!parentSection) return { ...json }; - const parentName = descriptor.nameParts[0]; if (!parentName) return { ...json }; @@ -220,12 +244,9 @@ function applyNestedOverride( function applyGrandchildOverride( descriptor: ResourceDescriptor, json: Record, - overrides: OverrideConfig, + parentSection: OverrideSection, mapping: { parentSection: OverrideSectionKey; childKey: string; grandchildKey: string } ): Record { - const parentSection = overrides[mapping.parentSection]; - if (!parentSection) return { ...json }; - const parentName = descriptor.nameParts[0]; if (!parentName) return { ...json }; diff --git a/tests/unit/services/override-merger.test.ts b/tests/unit/services/override-merger.test.ts index 13033d6d..a58d503a 100644 --- a/tests/unit/services/override-merger.test.ts +++ b/tests/unit/services/override-merger.test.ts @@ -655,4 +655,229 @@ describe('override-merger', () => { expect(result.properties).toHaveProperty('isCurrent', true); }); }); + + describe('workspace-scoped overrides', () => { + it('should apply a direct override for a workspace-scoped API', () => { + const descriptor: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: ['my-api'], + workspace: 'ws1', + }; + const json = { name: 'my-api', properties: { serviceUrl: 'https://dev.example.com' } }; + const overrideConfig: OverrideConfig = { + workspaces: { + ws1: { + properties: {}, + children: { + apis: { + 'my-api': { + properties: { serviceUrl: 'https://prod.example.com' }, + }, + }, + }, + }, + }, + }; + + const result = applyOverrides(descriptor, json, overrideConfig); + expect(result.properties).toHaveProperty('serviceUrl', 'https://prod.example.com'); + }); + + it('should apply a direct override for a workspace-scoped backend', () => { + const descriptor: ResourceDescriptor = { + type: ResourceType.Backend, + nameParts: ['orders-backend'], + workspace: 'partner-workspace', + }; + const json = { name: 'orders-backend', properties: { url: 'https://orders-dev.example.com' } }; + const overrideConfig: OverrideConfig = { + workspaces: { + 'partner-workspace': { + properties: {}, + children: { + backends: { + 'orders-backend': { + properties: { url: 'https://orders-prod.example.com' }, + }, + }, + }, + }, + }, + }; + + const result = applyOverrides(descriptor, json, overrideConfig); + expect(result.properties).toHaveProperty('url', 'https://orders-prod.example.com'); + }); + + it('should use the override from the matching workspace when multiple workspaces define the same resource name', () => { + const descriptor: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: ['my-api'], + workspace: 'ws2', + }; + const json = { name: 'my-api', properties: { serviceUrl: 'https://dev.example.com' } }; + const overrideConfig: OverrideConfig = { + workspaces: { + ws1: { + properties: {}, + children: { + apis: { + 'my-api': { + properties: { serviceUrl: 'https://ws1.example.com' }, + }, + }, + }, + }, + ws2: { + properties: {}, + children: { + apis: { + 'my-api': { + properties: { serviceUrl: 'https://ws2.example.com' }, + }, + }, + }, + }, + }, + }; + + const result = applyOverrides(descriptor, json, overrideConfig); + expect(result.properties).toHaveProperty('serviceUrl', 'https://ws2.example.com'); + }); + + it('should apply a nested override for a workspace-scoped API diagnostic', () => { + const descriptor: ResourceDescriptor = { + type: ResourceType.ApiDiagnostic, + nameParts: ['my-api', 'applicationinsights'], + workspace: 'ws1', + }; + const json = { name: 'applicationinsights', properties: { loggerId: '/old-logger' } }; + const overrideConfig: OverrideConfig = { + workspaces: { + ws1: { + properties: {}, + children: { + apis: { + 'my-api': { + properties: {}, + children: { + diagnostics: { + applicationinsights: { + properties: { loggerId: '/new-logger' }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + + const result = applyOverrides(descriptor, json, overrideConfig); + expect(result.properties).toHaveProperty('loggerId', '/new-logger'); + }); + + it('should apply a grandchild override for a workspace-scoped API operation policy', () => { + const descriptor: ResourceDescriptor = { + type: ResourceType.ApiOperationPolicy, + nameParts: ['my-api', 'get-pets'], + workspace: 'ws1', + }; + const json = { name: 'policy', properties: { format: 'xml' } }; + const overrideConfig: OverrideConfig = { + workspaces: { + ws1: { + properties: {}, + children: { + apis: { + 'my-api': { + properties: {}, + children: { + operations: { + 'get-pets': { + properties: {}, + children: { + policies: { + policy: { properties: { format: 'rawxml' } }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + + const result = applyOverrides(descriptor, json, overrideConfig); + expect(result.properties).toHaveProperty('format', 'rawxml'); + }); + + it('should not leak a top-level override into a workspace-scoped resource', () => { + const descriptor: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: ['my-api'], + workspace: 'ws1', + }; + const json = { name: 'my-api', properties: { serviceUrl: 'https://dev.example.com' } }; + // Same resource name is overridden at the top level, but no workspace-scoped override exists. + const overrideConfig: OverrideConfig = { + apis: { + 'my-api': { + properties: { serviceUrl: 'https://should-not-apply.example.com' }, + }, + }, + }; + + const result = applyOverrides(descriptor, json, overrideConfig); + expect(result).toEqual(json); + }); + + it('should still apply workspace container property overrides (no workspace field on descriptor)', () => { + const descriptor: ResourceDescriptor = { + type: ResourceType.Workspace, + nameParts: ['ws1'], + }; + const json = { name: 'ws1', properties: { displayName: 'Dev Workspace' } }; + const overrideConfig: OverrideConfig = { + workspaces: { + ws1: { + properties: { displayName: 'Prod Workspace' }, + }, + }, + }; + + const result = applyOverrides(descriptor, json, overrideConfig); + expect(result.properties).toHaveProperty('displayName', 'Prod Workspace'); + }); + + it('should match workspace names case-insensitively', () => { + const descriptor: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: ['my-api'], + workspace: 'ws1', + }; + const json = { name: 'my-api', properties: { serviceUrl: 'https://dev.example.com' } }; + const overrideConfig: OverrideConfig = { + workspaces: { + WS1: { + properties: {}, + children: { + apis: { + 'my-api': { + properties: { serviceUrl: 'https://prod.example.com' }, + }, + }, + }, + }, + }, + }; + + const result = applyOverrides(descriptor, json, overrideConfig); + expect(result.properties).toHaveProperty('serviceUrl', 'https://prod.example.com'); + }); + }); }); From 1430b045693c5c31c8cfdd725f8a9cfc7595686d Mon Sep 17 00:00:00 2001 From: Matthew Johnson <10487726+centreboard@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:26:27 +0100 Subject: [PATCH 2/4] feat: enhance named value override handling with workspace support and add unit tests --- src/services/override-merger.ts | 12 +++- src/services/publish-service.ts | 2 +- src/services/secret-redaction-guard.ts | 2 +- tests/unit/services/publish-service.test.ts | 77 +++++++++++++++++++++ 4 files changed, 88 insertions(+), 5 deletions(-) diff --git a/src/services/override-merger.ts b/src/services/override-merger.ts index ae283254..3781413f 100644 --- a/src/services/override-merger.ts +++ b/src/services/override-merger.ts @@ -65,10 +65,16 @@ const GRANDCHILD_OVERRIDE_MAP: Partial key.toLowerCase() === lowerName ); } diff --git a/src/services/publish-service.ts b/src/services/publish-service.ts index 517667a9..404949c4 100644 --- a/src/services/publish-service.ts +++ b/src/services/publish-service.ts @@ -667,7 +667,7 @@ function splitNamedValues( for (const descriptor of descriptors) { if (descriptor.type === ResourceType.NamedValue) { const name = getNamePart(descriptor.nameParts, 0); - if (isAutoGeneratedId(name) && !hasNamedValueOverride(name, overrides)) { + if (isAutoGeneratedId(name) && !hasNamedValueOverride(name, overrides, descriptor.workspace)) { logger.debug(`Skipping auto-generated named value: ${name}`); continue; } diff --git a/src/services/secret-redaction-guard.ts b/src/services/secret-redaction-guard.ts index ba1a8442..106a4817 100644 --- a/src/services/secret-redaction-guard.ts +++ b/src/services/secret-redaction-guard.ts @@ -111,7 +111,7 @@ async function scanNamedValue( // supplies an explicit override, so scanning them here would flag markers in // artifacts that are never sent to APIM. Mirror the publish filter exactly. const name = getNamePart(descriptor.nameParts, 0); - if (isAutoGeneratedId(name) && !hasNamedValueOverride(name, config.overrides)) { + if (isAutoGeneratedId(name) && !hasNamedValueOverride(name, config.overrides, descriptor.workspace)) { return undefined; } diff --git a/tests/unit/services/publish-service.test.ts b/tests/unit/services/publish-service.test.ts index 503c3bef..c710ddee 100644 --- a/tests/unit/services/publish-service.test.ts +++ b/tests/unit/services/publish-service.test.ts @@ -2089,5 +2089,82 @@ describe('publish-service', () => { expect(putNames).toContain('regular-nv'); expect(putNames).toContain(autoGenId); }); + + it('should publish workspace auto-generated named values when a workspace override exists', async () => { + const autoGenId = 'aabbccddeeff112233445566'; + const descriptor: ResourceDescriptor = { + type: ResourceType.NamedValue, + nameParts: [autoGenId], + workspace: 'team-a', + }; + const client = createMockClient(); + const store = createMockStore([descriptor]); + vi.mocked(store.readResource).mockResolvedValue({ + name: autoGenId, + properties: { value: 'source-key', secret: true }, + }); + + await runPublish(client, store, { + service: testContext, + sourceDir: '/source', + dryRun: false, + deleteUnmatched: false, + logLevel: LogLevel.INFO, + overrides: { + workspaces: { + 'team-a': { + properties: {}, + children: { + namedValues: { + [autoGenId]: { + properties: { value: 'workspace-key' }, + }, + }, + }, + }, + }, + }, + }); + + expect(client.putResource).toHaveBeenCalledWith( + testContext, + descriptor, + expect.objectContaining({ + properties: expect.objectContaining({ value: 'workspace-key' }), + }) + ); + }); + + it('should not publish a workspace auto-generated named value for a service-level-only override', async () => { + const autoGenId = 'aabbccddeeff112233445566'; + const descriptor: ResourceDescriptor = { + type: ResourceType.NamedValue, + nameParts: [autoGenId], + workspace: 'team-a', + }; + const client = createMockClient(); + const store = createMockStore([descriptor]); + vi.mocked(store.readResource).mockResolvedValue({ + name: autoGenId, + properties: { value: 'source-key', secret: true }, + }); + + await runPublish(client, store, { + service: testContext, + sourceDir: '/source', + dryRun: false, + deleteUnmatched: false, + logLevel: LogLevel.INFO, + overrides: { + namedValues: { + [autoGenId]: { + properties: { value: 'service-key' }, + }, + }, + }, + }); + + expect(client.putResource).not.toHaveBeenCalled(); + }); }); }); From 058fd6153624ce3050f74d562424731c3b83fc41 Mon Sep 17 00:00:00 2001 From: Matthew Johnson <10487726+centreboard@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:18:01 +0100 Subject: [PATCH 3/4] fix: apply workspace scoped overrides for authentication settings --- src/services/api-publisher.ts | 4 +- src/services/override-merger.ts | 15 +-- src/services/resource-publisher.ts | 17 +-- tests/unit/services/api-publisher.test.ts | 71 ++++++++++++- .../unit/services/resource-publisher.test.ts | 100 ++++++++++++++++++ 5 files changed, 188 insertions(+), 19 deletions(-) diff --git a/src/services/api-publisher.ts b/src/services/api-publisher.ts index f881de7c..4fbc7fa2 100644 --- a/src/services/api-publisher.ts +++ b/src/services/api-publisher.ts @@ -24,7 +24,7 @@ import { } from './resource-publisher.js'; import { mapDescriptor } from './env-mapper.js'; import { runParallel } from '../lib/parallel-runner.js'; -import { applyOverrides } from './override-merger.js'; +import { applyOverrides, resolveOverrideSection } from './override-merger.js'; import { logger } from '../lib/logger.js'; import { getNamePart, @@ -555,7 +555,7 @@ async function publishRootApi( json = normalizeApiAuthenticationSettings(json, { preferLegacyFields: prefersLegacyAuthOverride( getNamePart(descriptor.nameParts, 0), - config.overrides?.apis + resolveOverrideSection(config.overrides, 'apis', descriptor.workspace) ), }); const isCurrent = getApiIsCurrent(json); diff --git a/src/services/override-merger.ts b/src/services/override-merger.ts index 3781413f..216a27e7 100644 --- a/src/services/override-merger.ts +++ b/src/services/override-merger.ts @@ -14,7 +14,7 @@ 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 }; +export 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. @@ -71,7 +71,7 @@ export function hasNamedValueOverride( workspace?: string ): boolean { if (!overrides) return false; - const namedValues = resolveSection(overrides, 'namedValues', workspace); + const namedValues = resolveOverrideSection(overrides, 'namedValues', workspace); if (!namedValues) return false; const lowerName = name.toLowerCase(); return Object.keys(namedValues).some( @@ -97,7 +97,7 @@ export function applyOverrides( // Try direct override lookup first const directSection = OVERRIDE_SECTION_MAP[descriptor.type]; if (directSection) { - const section = resolveSection(overrides, directSection, descriptor.workspace); + const section = resolveOverrideSection(overrides, directSection, descriptor.workspace); if (!section) return { ...json }; return applyFromSection(descriptor, json, section); } @@ -105,7 +105,7 @@ export function applyOverrides( // Try nested child override lookup const childMapping = CHILD_OVERRIDE_MAP[descriptor.type]; if (childMapping) { - const parentSection = resolveSection(overrides, childMapping.parentSection, descriptor.workspace); + const parentSection = resolveOverrideSection(overrides, childMapping.parentSection, descriptor.workspace); if (!parentSection) return { ...json }; return applyNestedOverride(descriptor, json, parentSection, childMapping); } @@ -113,7 +113,7 @@ export function applyOverrides( // Try grandchild (3-level) override lookup const grandchildMapping = GRANDCHILD_OVERRIDE_MAP[descriptor.type]; if (grandchildMapping) { - const parentSection = resolveSection(overrides, grandchildMapping.parentSection, descriptor.workspace); + const parentSection = resolveOverrideSection(overrides, grandchildMapping.parentSection, descriptor.workspace); if (!parentSection) return { ...json }; return applyGrandchildOverride(descriptor, json, parentSection, grandchildMapping); } @@ -126,11 +126,12 @@ export function applyOverrides( * Workspace-scoped resources are strictly isolated: they only read from * `overrides.workspaces[workspace].children[sectionKey]`, never the top-level section. */ -function resolveSection( - overrides: OverrideConfig, +export function resolveOverrideSection( + overrides: OverrideConfig | undefined, sectionKey: OverrideSectionKey, workspace: string | undefined ): OverrideSection | undefined { + if (!overrides) return undefined; if (!workspace) { return overrides[sectionKey]; } diff --git a/src/services/resource-publisher.ts b/src/services/resource-publisher.ts index 9e3c1ef1..f876c175 100644 --- a/src/services/resource-publisher.ts +++ b/src/services/resource-publisher.ts @@ -16,7 +16,7 @@ import type { } from '../models/types.js'; import type { PublishConfig } from '../models/config.js'; import { ResourceType, RESOURCE_TYPE_METADATA, MANAGED_GATEWAY_NAME } from '../models/resource-types.js'; -import { applyOverrides } from './override-merger.js'; +import { applyOverrides, resolveOverrideSection } from './override-merger.js'; import { checkKeyVaultSecretAccess } from './keyvault-checker.js'; import { getNamePart, @@ -338,7 +338,7 @@ export async function publishResource( const hasDisplayNameOverride = hasExplicitPropertyOverride( canonicalName, 'displayName', - config.overrides?.namedValues, + resolveOverrideSection(config.overrides, 'namedValues', descriptor.workspace), ); if (!hasDisplayNameOverride) { const props = json.properties as Record | undefined; @@ -469,8 +469,9 @@ export async function publishResource( // validation errors in APIM's revision creation. if (descriptor.type === ResourceType.Api) { const apiName = getNamePart(descriptor.nameParts, 0); + const apiOverrides = resolveOverrideSection(config.overrides, 'apis', descriptor.workspace); json = normalizeApiAuthenticationSettings(json, { - preferLegacyFields: prefersLegacyAuthOverride(apiName, config.overrides?.apis), + preferLegacyFields: prefersLegacyAuthOverride(apiName, apiOverrides), }); if (apiName.includes(';rev=')) { const baseApiName = apiName.split(';rev=')[0]; @@ -498,7 +499,7 @@ export async function publishResource( } if ( Object.hasOwn(cleanProps, 'description') && - hasExplicitPropertyOverride(apiName, 'description', config.overrides?.apis) + hasExplicitPropertyOverride(apiName, 'description', apiOverrides) ) { logger.warn( `Ignoring 'description' override for revision '${apiName}': ` + @@ -1209,7 +1210,11 @@ export function applyApiPathPrefix( } const canonicalApiName = getNamePart(descriptor.nameParts, 0).split(';rev=')[0]; - if (hasExplicitPropertyOverride(canonicalApiName, 'path', config.overrides?.apis)) { + if (hasExplicitPropertyOverride( + canonicalApiName, + 'path', + resolveOverrideSection(config.overrides, 'apis', descriptor.workspace), + )) { return json; } @@ -1631,7 +1636,7 @@ async function rewriteNamedValueReferences( const hasDisplayNameOverride = hasExplicitPropertyOverride( ref, 'displayName', - overrides?.namedValues, + resolveOverrideSection(overrides, 'namedValues', descriptor.workspace), ); if (!hasDisplayNameOverride) { const baseDisplayName = displayName ?? ref; diff --git a/tests/unit/services/api-publisher.test.ts b/tests/unit/services/api-publisher.test.ts index 34460747..6e3592fa 100644 --- a/tests/unit/services/api-publisher.test.ts +++ b/tests/unit/services/api-publisher.test.ts @@ -25,10 +25,16 @@ vi.mock('../../../src/services/resource-publisher.js', async () => { }; }); -// Mock override-merger -vi.mock('../../../src/services/override-merger.js', () => ({ - applyOverrides: vi.fn((descriptor, json) => json), -})); +// Mock override-merger while retaining its workspace section resolver. +vi.mock('../../../src/services/override-merger.js', async () => { + const actual = await vi.importActual( + '../../../src/services/override-merger.js' + ); + return { + ...actual, + applyOverrides: vi.fn((_descriptor, json) => json), + }; +}); // Mock parallel-runner const mockRunParallel = vi.fn(); @@ -1446,6 +1452,63 @@ describe('api-publisher', () => { expect(client.putResource).toHaveBeenCalled(); }); + it('should honor workspace API auth and path overrides during root post-processing', async () => { + const client = createMockClient(); + const store = createMockStore(); + store.readResource.mockResolvedValue({ + name: 'orders-api', + properties: { + path: 'workspace/orders', + authenticationSettings: { + oAuth2: { authorizationServerId: 'workspace-server' }, + oAuth2AuthenticationSettings: [{ authorizationServerId: 'artifact-server' }], + }, + }, + }); + const descriptor: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: ['orders-api'], + workspace: 'team-a', + }; + const config: PublishConfig = { + ...testConfig, + envMapping: { + prefix: 'dev-', + suffix: '', + apiPathPrefix: 'dev/', + appliesTo: DEFAULT_APPLIES_TO, + }, + overrides: { + workspaces: { + 'team-a': { + properties: {}, + children: { + apis: { + 'orders-api': { + properties: { + path: 'workspace/orders', + authenticationSettings: { + oAuth2: { authorizationServerId: 'workspace-server' }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + + await publishApi(client, store, testContext, descriptor, config); + + const putJson = client.putResource.mock.calls[0]?.[2] as Record; + const properties = putJson.properties as Record; + expect(properties.path).toBe('workspace/orders'); + expect(properties.authenticationSettings).toEqual({ + oAuth2: { authorizationServerId: 'workspace-server' }, + }); + }); + it('should return failed result on exception', async () => { const client = createMockClient(); const store = createMockStore([]); diff --git a/tests/unit/services/resource-publisher.test.ts b/tests/unit/services/resource-publisher.test.ts index 61c6078e..1be04f9a 100644 --- a/tests/unit/services/resource-publisher.test.ts +++ b/tests/unit/services/resource-publisher.test.ts @@ -324,6 +324,106 @@ describe('resource-publisher', () => { ); }); + it('should honor workspace API auth and path overrides during post-processing', async () => { + const client = createMockClient(); + const store = createMockStore(); + store.readResource.mockResolvedValue({ + name: 'orders-api', + properties: { + path: 'orders', + authenticationSettings: { + oAuth2: { authorizationServerId: 'workspace-server' }, + oAuth2AuthenticationSettings: [{ authorizationServerId: 'artifact-server' }], + }, + }, + }); + const descriptor: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: ['orders-api'], + workspace: 'team-a', + }; + const config: PublishConfig = { + ...testConfig, + envMapping: { + prefix: 'dev-', + suffix: '', + apiPathPrefix: 'dev/', + appliesTo: new Set([ResourceType.Api]), + }, + overrides: { + workspaces: { + 'team-a': { + properties: {}, + children: { + apis: { + 'orders-api': { + properties: { + path: 'workspace/orders', + authenticationSettings: { + oAuth2: { authorizationServerId: 'workspace-server' }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + + await publishResource(client, store, testContext, descriptor, config); + + const putJson = client.putResource.mock.calls[0]?.[2] as Record; + const properties = putJson.properties as Record; + expect(properties.path).toBe('workspace/orders'); + expect(properties.authenticationSettings).toEqual({ + oAuth2: { authorizationServerId: 'workspace-server' }, + }); + }); + + it('should preserve an explicit workspace named-value displayName with environment affixes', async () => { + const client = createMockClient(); + const store = createMockStore(); + store.readResource.mockResolvedValue({ + name: 'api-key', + properties: { displayName: 'api-key', value: 'test-value' }, + }); + const descriptor: ResourceDescriptor = { + type: ResourceType.NamedValue, + nameParts: ['api-key'], + workspace: 'team-a', + }; + const config: PublishConfig = { + ...testConfig, + envMapping: { + prefix: 'dev-', + suffix: '', + appliesTo: new Set([ResourceType.NamedValue]), + }, + overrides: { + workspaces: { + 'team-a': { + properties: {}, + children: { + namedValues: { + 'api-key': { + properties: { displayName: 'workspace-api-key' }, + }, + }, + }, + }, + }, + }, + }; + + await publishResource(client, store, testContext, descriptor, config); + + const putJson = client.putResource.mock.calls[0]?.[2] as Record; + const properties = putJson.properties as Record; + expect(properties.displayName).toBe('workspace-api-key'); + expect(client.putResource.mock.calls[0]?.[1].nameParts).toEqual(['dev-api-key']); + }); + it('should preserve opaque JSON properties', async () => { const client = createMockClient(); const store = createMockStore(); From e1c5ad81136b10243d1976e402580493a6dde4eb Mon Sep 17 00:00:00 2001 From: Matthew Johnson <10487726+centreboard@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:19:05 +0100 Subject: [PATCH 4/4] add extra test for interaction with environment prefix --- .../unit/services/resource-publisher.test.ts | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/tests/unit/services/resource-publisher.test.ts b/tests/unit/services/resource-publisher.test.ts index 1be04f9a..b1f30dba 100644 --- a/tests/unit/services/resource-publisher.test.ts +++ b/tests/unit/services/resource-publisher.test.ts @@ -2034,6 +2034,63 @@ describe('resource-publisher', () => { expect(creds.instrumentationKey).toBe('{{Logger-Credentials--abc123}}'); }); + it('should keep an explicit workspace named-value displayName unprefixed in logger credentials', async () => { + const client = createMockClient(); + const store = createMockStore(); + const loggerJson = { + name: 'workspace-logger', + properties: { + loggerType: 'applicationInsights', + credentials: { instrumentationKey: '{{api-key}}' }, + }, + }; + store.readResource.mockImplementation(async (_dir: string, descriptor: ResourceDescriptor) => { + if (descriptor.type === ResourceType.Logger) return loggerJson; + if (descriptor.type === ResourceType.NamedValue && descriptor.nameParts[0] === 'api-key') { + return { + name: 'api-key', + properties: { displayName: 'api-key', secret: true }, + }; + } + return null; + }); + const descriptor: ResourceDescriptor = { + type: ResourceType.Logger, + nameParts: ['workspace-logger'], + workspace: 'team-a', + }; + const config: PublishConfig = { + ...testConfig, + envMapping: { + prefix: 'dev-', + suffix: '', + appliesTo: new Set([ResourceType.NamedValue]), + }, + overrides: { + workspaces: { + 'team-a': { + properties: {}, + children: { + namedValues: { + 'api-key': { + properties: { displayName: 'workspace-api-key' }, + }, + }, + }, + }, + }, + }, + }; + + const result = await publishResource(client, store, testContext, descriptor, config); + + expect(result.status).toBe('success'); + const putJson = client.putResource.mock.calls[0]?.[2] as Record; + const properties = putJson.properties as Record; + const credentials = properties.credentials as Record; + expect(credentials.instrumentationKey).toBe('{{workspace-api-key}}'); + }); + it('should not rewrite credentials for non-Logger resources', async () => { const client = createMockClient(); const store = createMockStore();