diff --git a/.talismanrc b/.talismanrc index dfcb2f2b9..7198dc581 100644 --- a/.talismanrc +++ b/.talismanrc @@ -9,4 +9,16 @@ fileignoreconfig: checksum: bc4a53f96be6a10786e00133245c7bdc43c965c8a98b753e3879e1110cf9c601 - filename: packages/contentstack-asset-management/test/unit/utils/cs-assets-api-adapter.test.ts checksum: 63c6bff4d51842d8fa3cce88545259d0a2c3cfe71df95d303d993f692cee883b +- filename: packages/contentstack-audit/src/modules/assets.ts + checksum: 3a2f3d338ab7d417ad24fbf3d8f25e51e3098a440857ebbc2156e72e208a284e +- filename: packages/contentstack-audit/test/unit/modules/assets.test.ts + checksum: ee776f2d485c89ecf67aec9e61e37350e5754988bfca4574bbaef3c732f014b1 +- filename: packages/contentstack-audit/test/unit/mock/am-contents/environments/environments.json + checksum: ffaaee9269a6e833cd3dbe337ddc5c060cce948519873c7a2777754519a31b52 +- filename: packages/contentstack-audit/test/unit/mock/am-contents/stack/stack.json + checksum: c4ac3ff80eb38b1c58eb0db21a232bd84f856d4647093542296199036945083c +- filename: packages/contentstack-audit/test/unit/mock/am-contents/spaces/space_one/assets/chunk0-assets.json + checksum: 9fc8b266ef58ecce8d3c4182632d1f7d088dfbce81b3fcea46b087cde3129b91 +- filename: packages/contentstack-audit/test/unit/mock/am-contents/spaces/space_clean/assets/chunk0-assets.json + checksum: f02886c04ea1097ebc5965fa9a63a878adb9437d56a5b1797be1967fae35f9e4 version: '1.0' diff --git a/packages/contentstack-audit/src/messages/index.ts b/packages/contentstack-audit/src/messages/index.ts index 76d7f3f3e..7a130702e 100644 --- a/packages/contentstack-audit/src/messages/index.ts +++ b/packages/contentstack-audit/src/messages/index.ts @@ -40,7 +40,10 @@ const auditMsg = { SCAN_CR_SUCCESS_MSG: 'Successfully completed the scanning of custom role with UID {uid} and name {name}.', SCAN_CS_SUCCESS_MSG: 'Successfully completed the scanning of studio project with UID {uid} and name {name}.', SCAN_ASSET_SUCCESS_MSG: `Successfully completed the scanning of Asset with UID '{uid}'.`, - SCAN_ASSET_WARN_MSG: `The locale '{locale}' or environment '{environment}' are not present for asset with uid '{uid}'`, + SCAN_ASSET_ENV_MISSING: `Environment '{environment}' is not present for asset '{uid}' (locale '{locale}')`, + SCAN_ASSET_LOCALE_MISSING: `Locale '{locale}' is not present for asset '{uid}' (environment '{environment}')`, + SCAN_ASSET_ENV_AND_LOCALE_MISSING: `Neither locale '{locale}' nor environment '{environment}' is present for asset '{uid}'`, + ASSET_PD_SOURCE_STACK_UNKNOWN: `Source stack API key not found at '{path}'; asset publish details tagged with an api_key were skipped and left unchanged.`, ENTRY_PUBLISH_DETAILS: `Removing the publish details for entry '{uid}' of ct '{ctuid}' in locale '{locale}' as locale '{publocale}' or environment '{environment}' does not exist`, CT_REFERENCE_FIELD: `The mentioned Reference field is not Array field reference is '{reference_to}' having display name '{display_name}''`, ASSET_NOT_EXIST: `The publish_details either does not exist or is not an array for asset uid '{uid}'`, diff --git a/packages/contentstack-audit/src/modules/assets.ts b/packages/contentstack-audit/src/modules/assets.ts index ff375a65a..3a3839e74 100644 --- a/packages/contentstack-audit/src/modules/assets.ts +++ b/packages/contentstack-audit/src/modules/assets.ts @@ -37,6 +37,10 @@ export default class Assets extends BaseClass { protected missingEnvLocales: Record = {}; public moduleName: keyof typeof auditConfig.moduleConfig; private fixOverwriteConfirmed: boolean | null = null; + /** API key of the exported stack; null when `stack/stack.json` is missing or unreadable. */ + private sourceStackApiKey: string | null = null; + /** Keeps the unknown-source-stack warning to one per run. */ + private unverifiablePdWarned = false; private resolvedBasePaths: Array<{ path: string; spaceId: string | null }> = []; /** Map space dir name → the per-space multibar row label, or empty when single-space. */ private spaceProcessNames: Map = new Map(); @@ -186,6 +190,32 @@ export default class Assets extends BaseClass { this.environments = existsSync(environmentPath) ? keys(JSON.parse(readFileSync(environmentPath, 'utf8'))) : []; log.debug(`Total environments loaded: ${this.environments.length}`, this.config.auditContext); log.debug(`Environment names: ${this.environments.join(', ')}`, this.config.auditContext); + + this.sourceStackApiKey = this.resolveSourceStackApiKey(); + log.debug( + this.sourceStackApiKey + ? `Source stack API key resolved from ${this.stackJsonPath()}` + : `Source stack API key not resolved from ${this.stackJsonPath()}`, + this.config.auditContext, + ); + } + + private stackJsonPath(): string { + return join(this.config.basePath, 'stack', 'stack.json'); + } + + /** Reads the exported stack's API key, used to scope Asset Management publish details to this stack. */ + private resolveSourceStackApiKey(): string | null { + const stackJsonPath = this.stackJsonPath(); + if (!existsSync(stackJsonPath)) return null; + + try { + const stackData = JSON.parse(readFileSync(stackJsonPath, 'utf8')); + return stackData?.api_key || stackData?.stackHeaders?.api_key || null; + } catch (error) { + log.debug(`Could not read ${stackJsonPath}: ${error}`, this.config.auditContext); + return null; + } } /** @@ -298,12 +328,17 @@ export default class Assets extends BaseClass { let indexer = fsUtility.indexFileContent; log.debug(`Found ${Object.keys(indexer).length} asset files to process`, this.config.auditContext); + let skippedCrossStack = 0; + for (const fileIndex in indexer) { log.debug(`Processing asset file: ${indexer[fileIndex]}`, this.config.auditContext); const assets = (await fsUtility.readChunkFiles.next()) as Record; this.assets = assets; log.debug(`Loaded ${Object.keys(assets).length} assets from file`, this.config.auditContext); + // Fix mode only prompts for and rewrites chunks that actually changed. + let chunkChanged = false; + for (const assetUid in assets) { log.debug(`Processing asset: ${assetUid}`, this.config.auditContext); @@ -311,18 +346,37 @@ export default class Assets extends BaseClass { log.debug(`Asset ${assetUid} has invalid publish_details format`, this.config.auditContext); cliux.print($t(auditMsg.ASSET_NOT_EXIST, { uid: assetUid }), { color: 'red' }); this.assets[assetUid].publish_details = []; + chunkChanged = true; } const publishDetails = this.assets[assetUid]?.publish_details; log.debug(`Asset ${assetUid} has ${publishDetails?.length || 0} publish details`, this.config.auditContext); if (Array.isArray(this.assets[assetUid].publish_details)) { + const originalPublishDetailsCount = this.assets[assetUid].publish_details.length; + this.assets[assetUid].publish_details = this.assets[assetUid].publish_details.filter((pd: any) => { log.debug( `Checking publish detail: locale=${pd?.locale}, environment=${pd?.environment}`, this.config.auditContext, ); + // An Asset Management asset is shared across stacks and carries publish details for each stack + // it was published into, tagged with that stack's api_key. Only this export's stack can be + // validated here; entries belonging to other stacks (or unattributable ones, when the source + // stack api_key is unknown) are left untouched. + if (pd?.api_key && pd.api_key !== this.sourceStackApiKey) { + if (!this.sourceStackApiKey && !this.unverifiablePdWarned) { + this.unverifiablePdWarned = true; + log.warn( + $t(auditMsg.ASSET_PD_SOURCE_STACK_UNKNOWN, { path: this.stackJsonPath() }), + this.config.auditContext, + ); + } + skippedCrossStack++; + return true; + } + if (this.locales?.includes(pd?.locale) && this.environments?.includes(pd?.environment)) { log.debug( `Publish detail valid for asset ${assetUid}: locale=${pd.locale}, environment=${pd.environment}`, @@ -334,10 +388,17 @@ export default class Assets extends BaseClass { `Publish detail invalid for asset ${assetUid}: locale=${pd.locale}, environment=${pd.environment}`, this.config.auditContext, ); - cliux.print( - $t(auditMsg.SCAN_ASSET_WARN_MSG, { uid: assetUid, locale: pd.locale, environment: pd.environment }), - { color: 'yellow' }, - ); + const localeMissing = !this.locales?.includes(pd?.locale); + const environmentMissing = !this.environments?.includes(pd?.environment); + const warnMsg = + localeMissing && environmentMissing + ? auditMsg.SCAN_ASSET_ENV_AND_LOCALE_MISSING + : localeMissing + ? auditMsg.SCAN_ASSET_LOCALE_MISSING + : auditMsg.SCAN_ASSET_ENV_MISSING; + cliux.print($t(warnMsg, { uid: assetUid, locale: pd.locale, environment: pd.environment }), { + color: 'yellow', + }); if (!Object.keys(this.missingEnvLocales).includes(assetUid)) { log.debug(`Creating new missing reference entry for asset ${assetUid}`, this.config.auditContext); this.missingEnvLocales[assetUid] = [ @@ -363,6 +424,10 @@ export default class Assets extends BaseClass { return false; } }); + + if (this.assets[assetUid].publish_details.length !== originalPublishDetailsCount) { + chunkChanged = true; + } } log.info($t(auditMsg.SCAN_ASSET_SUCCESS_MSG, { uid: assetUid }), this.config.auditContext); @@ -384,11 +449,23 @@ export default class Assets extends BaseClass { } } - if (this.fix) { + if (this.fix && chunkChanged) { await this.writeFixContent(`${spacePath}/${indexer[fileIndex]}`, this.assets); + } else if (this.fix) { + log.debug( + `No changes for ${indexer[fileIndex]} - skipping write and fix confirmation`, + this.config.auditContext, + ); } } + if (skippedCrossStack) { + log.info( + `Skipped ${skippedCrossStack} publish detail(s) of other stacks in ${spaceId ?? spacePath}`, + this.config.auditContext, + ); + } + // Per-space row finished — close it so the multibar shows ✓ Complete // and the next space (if any) starts cleanly. if (spaceProcessName) { diff --git a/packages/contentstack-audit/test/unit/mock/am-contents-no-stack/environments/environments.json b/packages/contentstack-audit/test/unit/mock/am-contents-no-stack/environments/environments.json new file mode 100644 index 000000000..c36d2d50a --- /dev/null +++ b/packages/contentstack-audit/test/unit/mock/am-contents-no-stack/environments/environments.json @@ -0,0 +1,6 @@ +{ + "env_own_dev": { + "name": "development", + "uid": "env_own_dev" + } +} diff --git a/packages/contentstack-audit/test/unit/mock/am-contents-no-stack/locales/master-locale.json b/packages/contentstack-audit/test/unit/mock/am-contents-no-stack/locales/master-locale.json new file mode 100644 index 000000000..e269391d2 --- /dev/null +++ b/packages/contentstack-audit/test/unit/mock/am-contents-no-stack/locales/master-locale.json @@ -0,0 +1,8 @@ +{ + "locale-uid-1": { + "code": "en-us", + "fallback_locale": null, + "uid": "locale-uid-1", + "name": "English - United States" + } +} diff --git a/packages/contentstack-audit/test/unit/mock/am-contents-no-stack/spaces/space_one/assets/assets.json b/packages/contentstack-audit/test/unit/mock/am-contents-no-stack/spaces/space_one/assets/assets.json new file mode 100644 index 000000000..7fc11d987 --- /dev/null +++ b/packages/contentstack-audit/test/unit/mock/am-contents-no-stack/spaces/space_one/assets/assets.json @@ -0,0 +1 @@ +{ "1": "chunk0-assets.json" } diff --git a/packages/contentstack-audit/test/unit/mock/am-contents-no-stack/spaces/space_one/assets/chunk0-assets.json b/packages/contentstack-audit/test/unit/mock/am-contents-no-stack/spaces/space_one/assets/chunk0-assets.json new file mode 100644 index 000000000..c957f42cd --- /dev/null +++ b/packages/contentstack-audit/test/unit/mock/am-contents-no-stack/spaces/space_one/assets/chunk0-assets.json @@ -0,0 +1,18 @@ +{ + "am_asset_tagged": { + "uid": "am_asset_tagged", + "title": "Api key tagged publish details, unverifiable without stack.json", + "publish_details": [ + { "locale": "en-us", "environment": "env_own_missing", "api_key": "blt_own_stack" }, + { "locale": "de-de", "environment": "env_other_dev", "api_key": "blt_other_stack_one" } + ] + }, + "am_asset_legacy_pd": { + "uid": "am_asset_legacy_pd", + "title": "Legacy publish details without api_key stay auditable", + "publish_details": [ + { "locale": "en-us", "environment": "env_own_dev" }, + { "locale": "en-us", "environment": "env_own_missing" } + ] + } +} diff --git a/packages/contentstack-audit/test/unit/mock/am-contents/environments/environments.json b/packages/contentstack-audit/test/unit/mock/am-contents/environments/environments.json new file mode 100644 index 000000000..e424c5a52 --- /dev/null +++ b/packages/contentstack-audit/test/unit/mock/am-contents/environments/environments.json @@ -0,0 +1,10 @@ +{ + "env_own_dev": { + "name": "development", + "uid": "env_own_dev" + }, + "env_own_prod": { + "name": "production", + "uid": "env_own_prod" + } +} diff --git a/packages/contentstack-audit/test/unit/mock/am-contents/locales/master-locale.json b/packages/contentstack-audit/test/unit/mock/am-contents/locales/master-locale.json new file mode 100644 index 000000000..e269391d2 --- /dev/null +++ b/packages/contentstack-audit/test/unit/mock/am-contents/locales/master-locale.json @@ -0,0 +1,8 @@ +{ + "locale-uid-1": { + "code": "en-us", + "fallback_locale": null, + "uid": "locale-uid-1", + "name": "English - United States" + } +} diff --git a/packages/contentstack-audit/test/unit/mock/am-contents/spaces/space_clean/assets/assets.json b/packages/contentstack-audit/test/unit/mock/am-contents/spaces/space_clean/assets/assets.json new file mode 100644 index 000000000..7fc11d987 --- /dev/null +++ b/packages/contentstack-audit/test/unit/mock/am-contents/spaces/space_clean/assets/assets.json @@ -0,0 +1 @@ +{ "1": "chunk0-assets.json" } diff --git a/packages/contentstack-audit/test/unit/mock/am-contents/spaces/space_clean/assets/chunk0-assets.json b/packages/contentstack-audit/test/unit/mock/am-contents/spaces/space_clean/assets/chunk0-assets.json new file mode 100644 index 000000000..937307d8f --- /dev/null +++ b/packages/contentstack-audit/test/unit/mock/am-contents/spaces/space_clean/assets/chunk0-assets.json @@ -0,0 +1,10 @@ +{ + "am_asset_clean": { + "uid": "am_asset_clean", + "title": "Nothing to fix here", + "publish_details": [ + { "locale": "en-us", "environment": "env_own_prod", "api_key": "blt_own_stack" }, + { "locale": "en-us", "environment": "env_other_prod", "api_key": "blt_other_stack_two" } + ] + } +} diff --git a/packages/contentstack-audit/test/unit/mock/am-contents/spaces/space_cross_only/assets/assets.json b/packages/contentstack-audit/test/unit/mock/am-contents/spaces/space_cross_only/assets/assets.json new file mode 100644 index 000000000..7fc11d987 --- /dev/null +++ b/packages/contentstack-audit/test/unit/mock/am-contents/spaces/space_cross_only/assets/assets.json @@ -0,0 +1 @@ +{ "1": "chunk0-assets.json" } diff --git a/packages/contentstack-audit/test/unit/mock/am-contents/spaces/space_cross_only/assets/chunk0-assets.json b/packages/contentstack-audit/test/unit/mock/am-contents/spaces/space_cross_only/assets/chunk0-assets.json new file mode 100644 index 000000000..dea64a82f --- /dev/null +++ b/packages/contentstack-audit/test/unit/mock/am-contents/spaces/space_cross_only/assets/chunk0-assets.json @@ -0,0 +1,19 @@ +{ + "am_asset_other_stacks_only_a": { + "uid": "am_asset_other_stacks_only_a", + "title": "Published only into other stacks, unknown env and locale here", + "publish_details": [ + { "locale": "en-us", "environment": "env_other_dev", "api_key": "blt_other_stack_one" }, + { "locale": "de-de", "environment": "env_other_prod", "api_key": "blt_other_stack_two" } + ] + }, + "am_asset_other_stacks_only_b": { + "uid": "am_asset_other_stacks_only_b", + "title": "Published only into other stacks, multiple environments", + "publish_details": [ + { "locale": "fr-fr", "environment": "env_other_dev", "api_key": "blt_other_stack_one" }, + { "locale": "fr-fr", "environment": "env_other_stage", "api_key": "blt_other_stack_one" }, + { "locale": "ja-jp", "environment": "env_other_prod", "api_key": "blt_other_stack_two" } + ] + } +} diff --git a/packages/contentstack-audit/test/unit/mock/am-contents/spaces/space_one/assets/assets.json b/packages/contentstack-audit/test/unit/mock/am-contents/spaces/space_one/assets/assets.json new file mode 100644 index 000000000..7fc11d987 --- /dev/null +++ b/packages/contentstack-audit/test/unit/mock/am-contents/spaces/space_one/assets/assets.json @@ -0,0 +1 @@ +{ "1": "chunk0-assets.json" } diff --git a/packages/contentstack-audit/test/unit/mock/am-contents/spaces/space_one/assets/chunk0-assets.json b/packages/contentstack-audit/test/unit/mock/am-contents/spaces/space_one/assets/chunk0-assets.json new file mode 100644 index 000000000..87649e47b --- /dev/null +++ b/packages/contentstack-audit/test/unit/mock/am-contents/spaces/space_one/assets/chunk0-assets.json @@ -0,0 +1,37 @@ +{ + "am_asset_cross_only": { + "uid": "am_asset_cross_only", + "title": "Published only into other stacks", + "publish_details": [ + { "locale": "en-us", "environment": "env_other_dev", "api_key": "blt_other_stack_one" }, + { "locale": "fr-fr", "environment": "env_other_prod", "api_key": "blt_other_stack_two" } + ] + }, + "am_asset_mixed": { + "uid": "am_asset_mixed", + "title": "Own stack plus other stacks", + "publish_details": [ + { "locale": "en-us", "environment": "env_own_dev", "api_key": "blt_own_stack" }, + { "locale": "en-us", "environment": "env_other_dev", "api_key": "blt_other_stack_one" }, + { "locale": "en-us", "environment": "env_own_missing", "api_key": "blt_own_stack" } + ] + }, + "am_asset_bad_locale": { + "uid": "am_asset_bad_locale", + "title": "Own stack with unknown locale", + "publish_details": [{ "locale": "de-de", "environment": "env_own_prod", "api_key": "blt_own_stack" }] + }, + "am_asset_bad_both": { + "uid": "am_asset_bad_both", + "title": "Own stack with unknown locale and environment", + "publish_details": [{ "locale": "de-de", "environment": "env_own_missing", "api_key": "blt_own_stack" }] + }, + "am_asset_legacy_pd": { + "uid": "am_asset_legacy_pd", + "title": "Legacy publish details without api_key", + "publish_details": [ + { "locale": "en-us", "environment": "env_own_dev" }, + { "locale": "en-us", "environment": "env_own_missing" } + ] + } +} diff --git a/packages/contentstack-audit/test/unit/mock/am-contents/stack/stack.json b/packages/contentstack-audit/test/unit/mock/am-contents/stack/stack.json new file mode 100644 index 000000000..d45bfa871 --- /dev/null +++ b/packages/contentstack-audit/test/unit/mock/am-contents/stack/stack.json @@ -0,0 +1,9 @@ +{ + "uid": "blt_own_stack_uid", + "name": "AM Mock Stack", + "api_key": "blt_own_stack", + "master_locale": "en-us", + "stackHeaders": { + "api_key": "blt_own_stack" + } +} diff --git a/packages/contentstack-audit/test/unit/modules/assets.test.ts b/packages/contentstack-audit/test/unit/modules/assets.test.ts index 9f83806ac..857621483 100644 --- a/packages/contentstack-audit/test/unit/modules/assets.test.ts +++ b/packages/contentstack-audit/test/unit/modules/assets.test.ts @@ -3,6 +3,7 @@ import { resolve } from 'path'; import { expect } from 'chai'; import fancy from 'fancy-test'; import Sinon from 'sinon'; +import isEmpty from 'lodash/isEmpty'; import { cliux } from '@contentstack/cli-utilities'; import config from '../../../src/config'; import { $t, auditMsg } from '../../../src/messages'; @@ -377,6 +378,220 @@ describe('Assets module', () => { }); }); + describe('AM cross-stack publish details', () => { + const amContentsPath = resolve(__dirname, '..', 'mock', 'am-contents'); + + const amParam = (overrides: Record = {}) => ({ + ...constructorParam, + ...overrides, + config: { ...constructorParam.config, basePath: amContentsPath, flags: {} as any, ...(overrides.config || {}) }, + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('should not report publish details owned by other stacks', async () => { + const instance = new Assets(amParam()); + await instance.prerequisiteData(); + await instance.lookForReference(); + const missing = (instance as any).missingEnvLocales; + expect(Object.keys(missing)).to.have.members([ + 'am_asset_mixed', + 'am_asset_bad_locale', + 'am_asset_bad_both', + 'am_asset_legacy_pd', + ]); + expect(missing).to.not.have.property('am_asset_cross_only'); + expect(missing).to.not.have.property('am_asset_clean'); + expect(missing.am_asset_mixed).to.have.lengthOf(1); + expect(missing.am_asset_mixed[0]).to.include({ + asset_uid: 'am_asset_mixed', + publish_locale: 'en-us', + publish_environment: 'env_own_missing', + space_id: 'space_one', + }); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('should keep other stacks publish details and only strip own-stack invalid ones on fix', async () => { + const instance = new Assets(amParam({ fix: true, config: { flags: { yes: true } as any } })); + await instance.prerequisiteData(); + const writeStub = Sinon.stub(fs, 'writeFileSync'); + await instance.lookForReference(); + + // space_clean has nothing to fix, so only space_one's chunk is written + expect(writeStub.callCount).to.equal(1); + expect(writeStub.firstCall.args[0]).to.include('space_one'); + + const written = JSON.parse(writeStub.firstCall.args[1] as string); + expect(written.am_asset_cross_only.publish_details).to.have.lengthOf(2); + expect(written.am_asset_mixed.publish_details.map((pd: any) => pd.environment)).to.eql([ + 'env_own_dev', + 'env_other_dev', + ]); + expect(written.am_asset_bad_locale.publish_details).to.have.lengthOf(0); + expect(written.am_asset_bad_both.publish_details).to.have.lengthOf(0); + expect(written.am_asset_legacy_pd.publish_details.map((pd: any) => pd.environment)).to.eql(['env_own_dev']); + writeStub.restore(); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('should not write or ask for confirmation when a chunk has nothing to fix', async () => { + const instance = new Assets(amParam({ fix: true })); + await instance.prerequisiteData(); + (instance as any).resolvedBasePaths = [ + { path: resolve(amContentsPath, 'spaces', 'space_clean', 'assets'), spaceId: 'space_clean' }, + ]; + const confirmStub = Sinon.stub(cliux, 'confirm').resolves(true); + const writeStub = Sinon.stub(fs, 'writeFileSync'); + await instance.lookForReference(); + expect(writeStub.called).to.be.false; + expect(confirmStub.called).to.be.false; + confirmStub.restore(); + writeStub.restore(); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('should name the missing environment, locale or both in the warning', async () => { + const instance = new Assets(amParam()); + await instance.prerequisiteData(); + const printStub = Sinon.stub(cliux, 'print'); + await instance.lookForReference(); + + const printed = printStub.getCalls().map((call: Sinon.SinonSpyCall) => call.args[0]); + expect(printed).to.include( + $t(auditMsg.SCAN_ASSET_ENV_MISSING, { + uid: 'am_asset_mixed', + locale: 'en-us', + environment: 'env_own_missing', + }), + ); + expect(printed).to.include( + $t(auditMsg.SCAN_ASSET_LOCALE_MISSING, { + uid: 'am_asset_bad_locale', + locale: 'de-de', + environment: 'env_own_prod', + }), + ); + expect(printed).to.include( + $t(auditMsg.SCAN_ASSET_ENV_AND_LOCALE_MISSING, { + uid: 'am_asset_bad_both', + locale: 'de-de', + environment: 'env_own_missing', + }), + ); + printStub.restore(); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('should skip api_key tagged publish details and warn once when source stack is unknown', async () => { + const warnStub = Sinon.stub(mockLogger, 'warn'); + const instance = new Assets(amParam()); + await instance.prerequisiteData(); + (instance as any).sourceStackApiKey = null; + await instance.lookForReference(); + + const missing = (instance as any).missingEnvLocales; + // Only the legacy publish detail (no api_key) stays auditable + expect(Object.keys(missing)).to.eql(['am_asset_legacy_pd']); + expect(warnStub.callCount).to.equal(1); + expect(warnStub.firstCall.args[0]).to.include('Source stack API key not found'); + warnStub.restore(); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('should resolve the source stack api key from stack/stack.json, null when absent', async () => { + const amInstance = new Assets(amParam()); + expect((amInstance as any).resolveSourceStackApiKey()).to.eql('blt_own_stack'); + + const legacyInstance = new Assets(constructorParam); + expect((legacyInstance as any).resolveSourceStackApiKey()).to.be.null; + }); + + describe('export whose assets are published only into other stacks (DX-9739)', () => { + const crossOnlyPaths = [ + { path: resolve(amContentsPath, 'spaces', 'space_cross_only', 'assets'), spaceId: 'space_cross_only' }, + ]; + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('should report nothing, so audit reports no asset issues', async () => { + const instance = new Assets(amParam()); + await instance.prerequisiteData(); + (instance as any).resolvedBasePaths = crossOnlyPaths; + const printStub = Sinon.stub(cliux, 'print'); + await instance.lookForReference(); + + // `hasFix` in audit-base-command is an OR of `!isEmpty(...)` across the module results, and + // an empty assets result is what keeps cm:stacks:import from raising the fix confirmation. + expect(isEmpty((instance as any).missingEnvLocales)).to.be.true; + expect(printStub.called).to.be.false; + printStub.restore(); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('should not prompt or write anything in fix mode', async () => { + const instance = new Assets(amParam({ fix: true })); + await instance.prerequisiteData(); + (instance as any).resolvedBasePaths = crossOnlyPaths; + const confirmStub = Sinon.stub(cliux, 'confirm').resolves(true); + const writeStub = Sinon.stub(fs, 'writeFileSync'); + await instance.lookForReference(); + + expect(confirmStub.called).to.be.false; + expect(writeStub.called).to.be.false; + expect(isEmpty((instance as any).missingEnvLocales)).to.be.true; + confirmStub.restore(); + writeStub.restore(); + }); + }); + + describe('export without stack/stack.json', () => { + const noStackPath = resolve(__dirname, '..', 'mock', 'am-contents-no-stack'); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('should audit only legacy publish details and warn once', async () => { + const warnStub = Sinon.stub(mockLogger, 'warn'); + const instance = new Assets(amParam({ config: { basePath: noStackPath } })); + await instance.prerequisiteData(); + expect((instance as any).sourceStackApiKey).to.be.null; + await instance.lookForReference(); + + const missing = (instance as any).missingEnvLocales; + expect(Object.keys(missing)).to.eql(['am_asset_legacy_pd']); + expect(missing.am_asset_legacy_pd).to.have.lengthOf(1); + expect(missing.am_asset_legacy_pd[0]).to.include({ publish_environment: 'env_own_missing' }); + expect(warnStub.callCount).to.equal(1); + expect(warnStub.firstCall.args[0]).to.include('Source stack API key not found'); + warnStub.restore(); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .it('should leave api_key tagged publish details untouched in fix mode', async () => { + Sinon.stub(mockLogger, 'warn'); + const instance = new Assets( + amParam({ fix: true, config: { basePath: noStackPath, flags: { yes: true } as any } }), + ); + await instance.prerequisiteData(); + const writeStub = Sinon.stub(fs, 'writeFileSync'); + await instance.lookForReference(); + + const written = JSON.parse(writeStub.firstCall.args[1] as string); + // Both tagged entries survive even though neither is valid for this export's env/locale set + expect(written.am_asset_tagged.publish_details).to.have.lengthOf(2); + expect(written.am_asset_legacy_pd.publish_details.map((pd: any) => pd.environment)).to.eql(['env_own_dev']); + writeStub.restore(); + }); + }); + }); + describe('integration-style run with real FsUtility', () => { fancy .stdout({ print: process.env.PRINT === 'true' || false })