From 95063df86c59ec8f503ccdfa2eec4b04cb510d92 Mon Sep 17 00:00:00 2001 From: naman-contentstack Date: Sat, 1 Aug 2026 15:04:36 +0530 Subject: [PATCH 1/2] fix(audit): scope asset publish details audit to the exported stack An Asset Management asset is shared across stacks and carries publish details for every stack it was published into, each tagged with that stack's api_key. The assets audit validated all of them against this export's locales and environments, so publish details belonging to other stacks were reported as missing environments/locales, stripped in fix mode, and triggered the fix confirmation prompt during import. The audit now resolves the exported stack's api key from stack/stack.json and validates only publish details for that stack, plus legacy ones without an api_key. Entries that cannot be attributed are left untouched and warned about once. Warning messages now name whether the locale, the environment, or both are missing, and fix mode writes a chunk only when it actually changed. Co-Authored-By: Claude Opus 5 (1M context) --- .talismanrc | 12 ++ .../contentstack-audit/src/messages/index.ts | 5 +- .../contentstack-audit/src/modules/assets.ts | 87 ++++++++++- .../environments/environments.json | 10 ++ .../am-contents/locales/master-locale.json | 8 ++ .../spaces/space_clean/assets/assets.json | 1 + .../space_clean/assets/chunk0-assets.json | 10 ++ .../spaces/space_one/assets/assets.json | 1 + .../space_one/assets/chunk0-assets.json | 37 +++++ .../unit/mock/am-contents/stack/stack.json | 9 ++ .../test/unit/modules/assets.test.ts | 135 ++++++++++++++++++ 11 files changed, 309 insertions(+), 6 deletions(-) create mode 100644 packages/contentstack-audit/test/unit/mock/am-contents/environments/environments.json create mode 100644 packages/contentstack-audit/test/unit/mock/am-contents/locales/master-locale.json create mode 100644 packages/contentstack-audit/test/unit/mock/am-contents/spaces/space_clean/assets/assets.json create mode 100644 packages/contentstack-audit/test/unit/mock/am-contents/spaces/space_clean/assets/chunk0-assets.json create mode 100644 packages/contentstack-audit/test/unit/mock/am-contents/spaces/space_one/assets/assets.json create mode 100644 packages/contentstack-audit/test/unit/mock/am-contents/spaces/space_one/assets/chunk0-assets.json create mode 100644 packages/contentstack-audit/test/unit/mock/am-contents/stack/stack.json 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..89cdf85a9 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.debug( + `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/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_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..5e2fba278 100644 --- a/packages/contentstack-audit/test/unit/modules/assets.test.ts +++ b/packages/contentstack-audit/test/unit/modules/assets.test.ts @@ -377,6 +377,141 @@ 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('integration-style run with real FsUtility', () => { fancy .stdout({ print: process.env.PRINT === 'true' || false }) From f6eaf007002a913cf5bb77e41a3523c2a29f646b Mon Sep 17 00:00:00 2001 From: naman-contentstack Date: Sat, 1 Aug 2026 18:34:46 +0530 Subject: [PATCH 2/2] test(audit): cover the DX-9739 symptom and the missing stack.json path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the cross-stack skip count at info level: it is the one number that tells an operator the scoping engaged, and at debug it never appears in a normal run. Add the cases the fix was missing: - an export whose assets are published only into other stacks reports nothing and prompts for nothing, which is the empty assets result cm:stacks:import reads as "no fix needed" — the actual ticket symptom, previously only asserted by proxy. - an AM export with no stack/stack.json exercises the unresolved-api-key path for real instead of poking the private field, and asserts fix mode leaves tagged publish details on disk rather than stripping them. All four fail when the api_key scoping is disabled. Co-Authored-By: Claude Opus 5 (1M context) --- .../contentstack-audit/src/modules/assets.ts | 2 +- .../environments/environments.json | 6 ++ .../locales/master-locale.json | 8 ++ .../spaces/space_one/assets/assets.json | 1 + .../space_one/assets/chunk0-assets.json | 18 +++++ .../space_cross_only/assets/assets.json | 1 + .../assets/chunk0-assets.json | 19 +++++ .../test/unit/modules/assets.test.ts | 80 +++++++++++++++++++ 8 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 packages/contentstack-audit/test/unit/mock/am-contents-no-stack/environments/environments.json create mode 100644 packages/contentstack-audit/test/unit/mock/am-contents-no-stack/locales/master-locale.json create mode 100644 packages/contentstack-audit/test/unit/mock/am-contents-no-stack/spaces/space_one/assets/assets.json create mode 100644 packages/contentstack-audit/test/unit/mock/am-contents-no-stack/spaces/space_one/assets/chunk0-assets.json create mode 100644 packages/contentstack-audit/test/unit/mock/am-contents/spaces/space_cross_only/assets/assets.json create mode 100644 packages/contentstack-audit/test/unit/mock/am-contents/spaces/space_cross_only/assets/chunk0-assets.json diff --git a/packages/contentstack-audit/src/modules/assets.ts b/packages/contentstack-audit/src/modules/assets.ts index 89cdf85a9..3a3839e74 100644 --- a/packages/contentstack-audit/src/modules/assets.ts +++ b/packages/contentstack-audit/src/modules/assets.ts @@ -460,7 +460,7 @@ export default class Assets extends BaseClass { } if (skippedCrossStack) { - log.debug( + log.info( `Skipped ${skippedCrossStack} publish detail(s) of other stacks in ${spaceId ?? spacePath}`, this.config.auditContext, ); 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/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/modules/assets.test.ts b/packages/contentstack-audit/test/unit/modules/assets.test.ts index 5e2fba278..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'; @@ -510,6 +511,85 @@ describe('Assets module', () => { 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', () => {