diff --git a/.talismanrc b/.talismanrc index 6385e95a..1c6a3017 100644 --- a/.talismanrc +++ b/.talismanrc @@ -1,4 +1,6 @@ fileignoreconfig: - filename: package-lock.json checksum: 80c8eb5270a1c4a0fa244d0f001d15867449d44954eb4d3f1ab7fa68c6f5446d -version: "" \ No newline at end of file +- filename: test/sanity-check/api/assetScanStatus-test.js + checksum: e3b1857fbe321e7125b55613acd58c3c0b2fd2462e7a14e48279ad35db4169e7 +version: "1.0" \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c51b844..5db03f2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [v1.31.0](https://github.com/contentstack/contentstack-management-javascript/tree/v1.31.0) (2026-07-27) + +- Enh + - Entry variants: `contentType(...).entry(...).variants(variantUidOrUids, branchName?)` — optional second argument sets the CMA `branch` header for that variants scope (branch UID or alias). First argument accepts a variant UID string or an array of UIDs (comma-separated in the request path). Omitting `branchName` preserves previous behavior. + - Entry variants: added `variants(uid).publish()` and `variants(uid).unpublish()`, calling the entry publish/unpublish endpoints with the variant payload nested under `entry`. + - `publish()`/`unpublish()` (entry and entry variants) accept optional `headers` and `params`, merged into the underlying HTTP request. +- Test + - Unit tests and sanity API tests for entry variants with an explicit branch, and for the new variant `publish()`/`unpublish()` methods. + ## [v1.30.4](https://github.com/contentstack/contentstack-management-javascript/tree/v1.30.4) (2026-06-29) - Update dependencies diff --git a/lib/entity.js b/lib/entity.js index 6bd45e4c..005e0550 100644 --- a/lib/entity.js +++ b/lib/entity.js @@ -13,7 +13,7 @@ import ContentstackCollection from './contentstackCollection' * await publishFn.call(entryInstance, { publishDetails: {...}, locale: 'en-us' }) */ export const publish = (http, type) => { - return async function ({ publishDetails, locale = null, version = null, scheduledAt = null }) { + return async function ({ publishDetails, locale = null, version = null, scheduledAt = null, headers: extraHeaders = {}, params = {} }) { const url = this.urlPath + '/publish' const headers = { headers: { @@ -22,7 +22,10 @@ export const publish = (http, type) => { } || {} const httpBody = {} httpBody[type] = cloneDeep(publishDetails) - return publishUnpublish(http, url, httpBody, headers, locale, version, scheduledAt) + return publishUnpublish(http, url, httpBody, headers, locale, version, scheduledAt, { + headers: extraHeaders, + params + }) } } @@ -36,7 +39,7 @@ export const publish = (http, type) => { * await unpublishFn.call(entryInstance, { publishDetails: {...}, locale: 'en-us' }) */ export const unpublish = (http, type) => { - return async function ({ publishDetails, locale = null, version = null, scheduledAt = null }) { + return async function ({ publishDetails, locale = null, version = null, scheduledAt = null, headers: extraHeaders = {}, params = {} }) { const url = this.urlPath + '/unpublish' const headers = { headers: { @@ -45,7 +48,10 @@ export const unpublish = (http, type) => { } || {} const httpBody = {} httpBody[type] = cloneDeep(publishDetails) - return publishUnpublish(http, url, httpBody, headers, locale, version, scheduledAt) + return publishUnpublish(http, url, httpBody, headers, locale, version, scheduledAt, { + headers: extraHeaders, + params + }) } } @@ -58,11 +64,12 @@ export const unpublish = (http, type) => { * @param {string|null} locale - Locale code. * @param {number|null} version - Version number. * @param {string|null} scheduledAt - Scheduled date/time in ISO format. + * @param {Object} [configExtras={}] - Optional `{ headers, params }` merged into the axios request (stack headers stay in `headers.headers`). * @returns {Promise} Promise that resolves to response data. * @async * @private */ -export const publishUnpublish = async (http, url, httpBody, headers, locale = null, version = null, scheduledAt = null) => { +export const publishUnpublish = async (http, url, httpBody, headers, locale = null, version = null, scheduledAt = null, configExtras = {}) => { if (locale !== null) { httpBody.locale = locale } @@ -72,8 +79,18 @@ export const publishUnpublish = async (http, url, httpBody, headers, locale = nu if (scheduledAt !== null) { httpBody.scheduled_at = scheduledAt } + const requestConfig = { + headers: { + ...cloneDeep(headers?.headers || {}), + ...cloneDeep(configExtras?.headers || {}) + }, + params: { + ...cloneDeep(headers?.params || {}), + ...cloneDeep(configExtras?.params || {}) + } + } try { - const response = await http.post(url, httpBody, headers) + const response = await http.post(url, httpBody, requestConfig) if (response.data) { const data = response.data || {} if (http?.httpClientParams?.headers?.api_version) { diff --git a/lib/stack/contentType/entry/index.js b/lib/stack/contentType/entry/index.js index 0f82561f..2901986d 100644 --- a/lib/stack/contentType/entry/index.js +++ b/lib/stack/contentType/entry/index.js @@ -285,7 +285,8 @@ export function Entry (http, data) { * @description The variants call returns a Variants instance for managing variants of an entry. * @memberof Entry * @func variants - * @param {String=} uid - Variant UID. If not provided, returns Variants instance for querying all variants. + * @param {string|string[]=} variantUidOrUids - Variant UID, list of UIDs (comma-separated in the path), or omit to query all variants. + * @param {string=} branchName - Optional branch UID or alias for this variants scope (sent as the branch header). Omit to use the stack default branch. * @returns {Variants} Instance of Variants. * @example * import * as contentstack from '@contentstack/management' @@ -293,14 +294,51 @@ export function Entry (http, data) { * const variants = client.stack({ api_key: 'api_key'}).contentType('content_type_uid').entry('uid').variants('uid') * variants.fetch() * .then((response) => console.log(response)); + * @example + * client.stack({ api_key: 'api_key'}).contentType('content_type_uid').entry('uid').variants('uid', 'branch_name').update(data) + * .then((response) => console.log(response)); */ - this.variants = (uid = null) => { - const data = { stackHeaders: this.stackHeaders } - data.content_type_uid = this.content_type_uid - data.entry_uid = this.uid - if (uid) { - data.variants_uid = uid + this.variants = (variantUidOrUids, branchName) => { + const uidInput = variantUidOrUids === undefined ? null : variantUidOrUids + const branch = + typeof branchName === 'string' && branchName !== '' + ? branchName + : undefined + + const data = { + content_type_uid: this.content_type_uid, + entry_uid: this.uid + } + + if (branch === undefined) { + data.stackHeaders = this.stackHeaders + } else { + data.stackHeaders = { + ...cloneDeep(this.stackHeaders || {}), + branch + } } + + let variantsUid = null + if (Array.isArray(uidInput)) { + const uids = uidInput.filter( + (uid) => typeof uid === 'string' && uid.length > 0 + ) + if (uids.length === 1) { + variantsUid = uids[0] + } else if (uids.length > 1) { + variantsUid = uids.join(',') + } + } else if (typeof uidInput === 'string' && uidInput.length > 0) { + variantsUid = uidInput + } else if (uidInput != null && uidInput !== '') { + variantsUid = uidInput + } + + if (variantsUid != null && variantsUid !== '') { + data.variants_uid = variantsUid + } + return new Variants(http, data) } diff --git a/lib/stack/contentType/entry/variants/index.js b/lib/stack/contentType/entry/variants/index.js index 27f3f56f..c15a1968 100644 --- a/lib/stack/contentType/entry/variants/index.js +++ b/lib/stack/contentType/entry/variants/index.js @@ -2,7 +2,8 @@ import cloneDeep from 'lodash/cloneDeep' import { deleteEntity, fetch, - query + query, + publishUnpublish } from '../../../../entity' import error from '../../../../core/contentstackError' @@ -14,8 +15,19 @@ import { bindModuleHeaders } from '../../../../core/moduleHeaderSupport.js' export function Variants (http, data) { Object.assign(this, cloneDeep(data)) this.urlPath = `/content_types/${this.content_type_uid}/entries/${this.entry_uid}/variants` - if (data && data.variants_uid) { - this.urlPath += `/${this.variants_uid}` + let variantPathSegment = '' + if (data?.variants_uid != null && data.variants_uid !== '') { + if (Array.isArray(data.variants_uid)) { + variantPathSegment = data.variants_uid + .filter((uid) => typeof uid === 'string' && uid.length > 0) + .join(',') + } else { + variantPathSegment = String(data.variants_uid) + } + } + if (variantPathSegment) { + this.urlPath += `/${variantPathSegment}` + const entryBaseUrlPath = `/content_types/${this.content_type_uid}/entries/${this.entry_uid}` /** * @description The Update a variant call updates an existing variant for the selected content type. * @memberof Variants @@ -38,7 +50,7 @@ export function Variants (http, data) { * } * } * } - * client.stack({ api_key: 'api_key'}).contentType('content_type_uid').entry('entry_uid').variants('uid').update(data) + * client.stack({ api_key: 'api_key'}).contentType('content_type_uid').entry('entry_uid').variants('uid', 'branch_name').update(data) * .then((variants) => console.log(variants)) */ this.update = async (data, params = {}) => { @@ -121,6 +133,88 @@ export function Variants (http, data) { return error(err) } } + + /** + * @description Publishes via the entry publish endpoint (POST .../entries/{entry_uid}/publish). Pass `publishDetails` as the object nested under `entry` (environments, locales, variants, variant_rules, etc.). Optional `headers` and `params` are merged into the HTTP request. + * @memberof Variants + * @func publish + * @param {Object} options + * @param {Object} options.publishDetails - Payload for the `entry` property (e.g. environments, locales, variants, variant_rules). + * @param {String|null} [options.locale] - Top-level `locale` on the request body. + * @param {Number|null} [options.version] - Top-level `version` on the request body. + * @param {String|null} [options.scheduledAt] - Top-level `scheduled_at` (ISO) on the request body. + * @param {Object} [options.headers={}] - Extra request headers merged with stack headers. + * @param {Object} [options.params={}] - Query string parameters for the request. + * @returns {Promise} Response data (e.g. notice, job_id). + * @example + * import * as contentstack from '@contentstack/management' + * const client = contentstack.client() + * await client.stack({ api_key: 'api_key' }).contentType('ct').entry('entry_uid').variants('variant_uid').publish({ + * publishDetails: { + * environments: ['production'], + * locales: ['en-us'], + * variants: [{ uid: 'variant_uid', version: 1 }], + * variant_rules: { publish_latest_base: false, publish_latest_base_conditionally: true } + * }, + * locale: 'en-us' + * }) + */ + this.publish = async ({ + publishDetails, + locale = null, + version = null, + scheduledAt = null, + headers: extraHeaders = {}, + params = {} + }) => { + const url = `${entryBaseUrlPath}/publish` + const httpBody = {} + httpBody.entry = cloneDeep(publishDetails) + const baseHeaders = { + headers: { + ...cloneDeep(this.stackHeaders) + } + } + return publishUnpublish(http, url, httpBody, baseHeaders, locale, version, scheduledAt, { + headers: extraHeaders, + params + }) + } + + /** + * @description Unpublishes via the entry unpublish endpoint (POST .../entries/{entry_uid}/unpublish). Pass `publishDetails` as the object nested under `entry`. Optional `headers` and `params` are merged into the HTTP request. + * @memberof Variants + * @func unpublish + * @param {Object} options + * @param {Object} options.publishDetails - Payload for the `entry` property (e.g. environments, locales, variants). + * @param {String|null} [options.locale] - Top-level `locale` on the request body. + * @param {Number|null} [options.version] - Top-level `version` on the request body. + * @param {String|null} [options.scheduledAt] - Top-level `scheduled_at` (ISO) on the request body. + * @param {Object} [options.headers={}] - Extra request headers merged with stack headers. + * @param {Object} [options.params={}] - Query string parameters for the request. + * @returns {Promise} Response data (e.g. notice, job_id). + */ + this.unpublish = async ({ + publishDetails, + locale = null, + version = null, + scheduledAt = null, + headers: extraHeaders = {}, + params = {} + }) => { + const url = `${entryBaseUrlPath}/unpublish` + const httpBody = {} + httpBody.entry = cloneDeep(publishDetails) + const baseHeaders = { + headers: { + ...cloneDeep(this.stackHeaders) + } + } + return publishUnpublish(http, url, httpBody, baseHeaders, locale, version, scheduledAt, { + headers: extraHeaders, + params + }) + } } else { /** * @description The Query on Variants will allow you to fetch details of all or specific Variants. diff --git a/package-lock.json b/package-lock.json index 58923100..952daa3a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@contentstack/management", - "version": "1.30.4", + "version": "1.31.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@contentstack/management", - "version": "1.30.4", + "version": "1.31.0", "license": "MIT", "dependencies": { "@contentstack/utils": "^1.9.1", diff --git a/package.json b/package.json index 49e11d10..f4bd95b0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@contentstack/management", - "version": "1.30.4", + "version": "1.31.0", "description": "The Content Management API is used to manage the content of your Contentstack account", "main": "./dist/node/contentstack-management.js", "browser": "./dist/web/contentstack-management.js", diff --git a/test/sanity-check/api/assetScanStatus-test.js b/test/sanity-check/api/assetScanStatus-test.js new file mode 100644 index 00000000..a37de198 --- /dev/null +++ b/test/sanity-check/api/assetScanStatus-test.js @@ -0,0 +1,1192 @@ +/** + * Asset Scan Status - Comprehensive Integration Tests + * + * Based on: "Asset Scanning Support – SDK Design Document" + * + * Tests the `include_asset_scan_status` API parameter across two org contexts: + * + * Part 1 – Non-AM Org (ORGANIZATION, scan plan enabled) + * Stack is the dynamic stack created by testSetup under process.env.ORGANIZATION. + * Uses process.env.API_KEY set at runtime. + * + * Part 2 – AM Org (AM_ORG_UID, DAM / Contentstack Assets + scan enabled) + * A stack is created dynamically inside AM_ORG_UID using the same authtoken + * obtained during main setup. No static AM_API_KEY required. + * All tests in Part 2 are skipped when AM_ORG_UID is not set. + * + * Bug surface these tests cover (per design doc): + * § 3.1 - Scan status missing/leaking on fetch and list + * § 3.1 - Param silently dropped when combined with version/locale/pagination + * § 3.2 - Upload response does NOT include status even when param is passed + * § 3.3 - SDK swallowing download errors for pending/quarantined assets + * (error codes: asset_scan_pending / asset_scan_quarantined → 422) + * § 3.4 - Publish blocking synchronously on scan status (should be async) + * § 3.5 - Bulk publish blocking synchronously on scan status + * § 3.6 - Legacy asset null status causing SDK to crash or fail validation + * § 4.2 - api_version: 3.2 header bleeding into non-publish SDK calls + * General - Status not reset to 'pending' after file replace + * General - Folder entries incorrectly receiving a scan status field + * General - Status inconsistent between single-fetch and list endpoints + */ + +import { expect } from 'chai' +import { describe, it, before, after } from 'mocha' +import { contentstackClient } from '../utility/ContentstackClient.js' +import { testData, wait, trackedExpect } from '../utility/testHelpers.js' +import * as testSetup from '../utility/testSetup.js' +import path from 'path' +import fs from 'fs' +import os from 'os' + +const testBaseDir = path.resolve(process.cwd(), 'test/sanity-check') +const assetPath = path.join(testBaseDir, 'mock/assets/image-1.jpg') + +// Valid enum values per SDK design doc § 3.1 + § 3.6 +// null is also valid for legacy assets uploaded before scanning was enabled (§ 3.6) +const VALID_SCAN_STATUSES = ['pending', 'clean', 'quarantined', 'not_scanned'] + +/** + * Accept enum string values OR null (legacy assets uploaded before scan was enabled). + * § 3.6: "Assets uploaded before scanning was enabled will have _asset_scan_status = null" + */ +function isValidScanStatusOrLegacy (value) { + return value === null || VALID_SCAN_STATUSES.includes(value) +} + +// ============================================================================ +// Helpers +// ============================================================================ + +function buildStack (apiKey) { + return contentstackClient().stack({ api_key: apiKey }) +} + +/** + * Upload a fresh asset dedicated to scan tests and return its UID. + * Returns null on failure so individual tests can self-skip. + */ +async function uploadScanAsset (stack, label) { + try { + const asset = await stack.asset().create({ + upload: assetPath, + title: `Scan Test ${label} ${Date.now()}`, + description: 'Dedicated asset for scan-status integration tests' + }) + await wait(2000) + return asset.uid + } catch (e) { + console.log(` [scan-test] Upload failed (${label}):`, e.errorMessage || e.message) + return null + } +} + +// EICAR antivirus test signature — stored base64-encoded so the source file is +// never flagged by repo scanners. Decodes to the standard 68-byte EICAR string +// that all AV engines recognise as a test virus (harmless, triggers quarantine). +const EICAR_BASE64 = 'WDVPIVAlQEFQWzRcUFpYNTQoUF4pN0NDKTd9JEVJQ0FSLVNUQU5EQVJELUFOVElWSVJVUy1URVNULUZJTEUhJEgrSCo=' + +function createEicarFile () { + const tmpPath = path.join(os.tmpdir(), `eicar-scan-test-${Date.now()}.com`) + fs.writeFileSync(tmpPath, Buffer.from(EICAR_BASE64, 'base64')) + return tmpPath +} + +/** + * Poll _asset_scan_status every `interval` ms until it equals `expectedStatus` + * or `timeout` ms elapses. Returns the last observed status. + * `not_scanned` is treated as terminal — the scanner will never change it. + */ +async function waitForScan (stack, assetUid, expectedStatus, timeout = 60000, interval = 3000) { + const deadline = Date.now() + timeout + let last = null + while (Date.now() < deadline) { + try { + const asset = await stack.asset(assetUid).fetch({ include_asset_scan_status: true }) + last = asset._asset_scan_status + if (last === expectedStatus) return last + if (last === 'not_scanned') return last // feature disabled — won't change + } catch (e) { /* transient network error — keep polling */ } + await wait(interval) + } + return last +} + +// ============================================================================ +// Part 1 – Non-AM Org (ORGANIZATION, scan enabled) +// ============================================================================ + +describe('Asset Scan Status – Non-AM Org (ORGANIZATION)', () => { + let stack + let freshAssetUid // uploaded at the start of this suite for scan-specific assertions + let replaceAssetUid // separate asset used for the replace-then-check test + + before(function () { + const apiKey = process.env.API_KEY + if (!apiKey) { + console.log(' [scan-test] API_KEY not set — skipping Non-AM Org suite') + this.skip() + } + stack = buildStack(apiKey) + }) + + before(async function () { + this.timeout(60000) + // Upload two fresh assets so the replace test does not disturb the main one + freshAssetUid = await uploadScanAsset(stack, 'main') + replaceAssetUid = await uploadScanAsset(stack, 'replace') + console.log(` [scan-test] freshAssetUid=${freshAssetUid} replaceAssetUid=${replaceAssetUid}`) + }) + + after(async function () { + // Clean up the assets we uploaded for this suite + for (const uid of [freshAssetUid, replaceAssetUid]) { + if (uid) { + try { await stack.asset(uid).delete() } catch (e) { /* ignore */ } + } + } + }) + + // -------------------------------------------------------------------------- + // 1. Newly uploaded asset should start with status = pending + // -------------------------------------------------------------------------- + it('should return _asset_scan_status for a freshly uploaded asset', async function () { + this.timeout(15000) + if (!freshAssetUid) return this.skip() + + const asset = await stack.asset(freshAssetUid).fetch({ include_asset_scan_status: true }) + + trackedExpect(asset, 'Asset response').toBeAn('object') + trackedExpect(asset.uid, 'Asset UID').toEqual(freshAssetUid) + // Field must be present when param is passed and scan plan is active + if ('_asset_scan_status' in asset) { + trackedExpect(asset._asset_scan_status, 'Scan status').toBeA('string') + expect(VALID_SCAN_STATUSES).to.include(asset._asset_scan_status) + } + // On a scan-enabled org, a just-uploaded asset should be 'pending' (not yet scanned) + // We accept 'clean' too in case scanning is near-instant on dev11 + if (asset._asset_scan_status) { + expect(['pending', 'clean']).to.include( + asset._asset_scan_status, + `Expected freshly uploaded asset to start as pending or clean, got: ${asset._asset_scan_status}` + ) + } + }) + + // -------------------------------------------------------------------------- + // 2. Status value must be a valid enum string (not null / undefined / empty) + // -------------------------------------------------------------------------- + it('scan status value must be a valid non-empty enum string', async function () { + this.timeout(15000) + if (!freshAssetUid) return this.skip() + + const asset = await stack.asset(freshAssetUid).fetch({ include_asset_scan_status: true }) + + if (!('_asset_scan_status' in asset)) return // Feature not active on this stack — skip silently + + expect(asset._asset_scan_status).to.be.a('string') + expect(asset._asset_scan_status.trim().length).to.be.greaterThan(0) + expect(VALID_SCAN_STATUSES).to.include(asset._asset_scan_status) + }) + + // -------------------------------------------------------------------------- + // 3. Status must NOT appear when param is omitted (single fetch) + // -------------------------------------------------------------------------- + it('should NOT return _asset_scan_status on single fetch when param is omitted', async function () { + this.timeout(15000) + if (!freshAssetUid) return this.skip() + + const asset = await stack.asset(freshAssetUid).fetch() + + expect(asset).to.be.an('object') + expect(asset).to.not.have.property('_asset_scan_status') + }) + + // -------------------------------------------------------------------------- + // 4. Every item in a list query should have the field when param is passed + // -------------------------------------------------------------------------- + it('should include _asset_scan_status on EVERY item in list query when param is passed', async function () { + this.timeout(15000) + + const response = await stack.asset().query({ include_asset_scan_status: true, limit: 10 }).find() + + expect(response).to.be.an('object') + expect(response.items).to.be.an('array') + + if (response.items.length === 0) return // Nothing to check + + const nonFileItems = response.items.filter(item => !item.is_dir) + if (nonFileItems.length === 0) return + + nonFileItems.forEach(item => { + if ('_asset_scan_status' in item) { + // § 3.6: null is valid for legacy assets uploaded before scan was enabled + expect(isValidScanStatusOrLegacy(item._asset_scan_status)).to.equal(true, + `Item ${item.uid} has invalid _asset_scan_status: ${JSON.stringify(item._asset_scan_status)}`) + } + }) + }) + + // -------------------------------------------------------------------------- + // 5. Status must NOT appear on list items when param is omitted + // -------------------------------------------------------------------------- + it('should NOT include _asset_scan_status on list items when param is omitted', async function () { + this.timeout(15000) + + const response = await stack.asset().query({ limit: 10 }).find() + + expect(response).to.be.an('object') + expect(response.items).to.be.an('array') + + response.items.filter(i => !i.is_dir).forEach(item => { + expect(item).to.not.have.property('_asset_scan_status') + }) + }) + + // -------------------------------------------------------------------------- + // 6. Status must be consistent between single fetch and list query for same asset + // -------------------------------------------------------------------------- + it('should return the same scan status in single fetch and list query for the same asset', async function () { + this.timeout(15000) + if (!freshAssetUid) return this.skip() + + const [singleResp, listResp] = await Promise.all([ + stack.asset(freshAssetUid).fetch({ include_asset_scan_status: true }), + stack.asset().query({ include_asset_scan_status: true, limit: 100 }).find() + ]) + + const singleStatus = singleResp._asset_scan_status + if (!singleStatus) return // Feature not active — skip silently + + const listItem = listResp.items.find(a => a.uid === freshAssetUid) + if (!listItem) return // Asset not in first page — acceptable, skip + + expect(listItem._asset_scan_status).to.equal(singleStatus, + `Inconsistent scan status — fetch: "${singleStatus}", list: "${listItem._asset_scan_status}"`) + }) + + // -------------------------------------------------------------------------- + // 7. Combined with version param — status must still be included + // -------------------------------------------------------------------------- + it('should include scan status when combined with version=1 query param', async function () { + this.timeout(15000) + if (!freshAssetUid) return this.skip() + + const asset = await stack.asset(freshAssetUid).fetch({ include_asset_scan_status: true, version: 1 }) + + expect(asset).to.be.an('object') + expect(asset.uid).to.equal(freshAssetUid) + // If status appears without version, it must also appear with version + if ('_asset_scan_status' in asset) { + expect(VALID_SCAN_STATUSES).to.include(asset._asset_scan_status) + } + }) + + // -------------------------------------------------------------------------- + // 8. Combined with locale param — status must still be included + // -------------------------------------------------------------------------- + it('should include scan status when combined with locale=en-us query param', async function () { + this.timeout(15000) + if (!freshAssetUid) return this.skip() + + const asset = await stack.asset(freshAssetUid).fetch({ include_asset_scan_status: true, locale: 'en-us' }) + + expect(asset).to.be.an('object') + if ('_asset_scan_status' in asset) { + expect(VALID_SCAN_STATUSES).to.include(asset._asset_scan_status) + } + }) + + // -------------------------------------------------------------------------- + // 9. After replacing the asset file, status should reset to 'pending' + // -------------------------------------------------------------------------- + it('should reset _asset_scan_status to pending after asset file is replaced', async function () { + this.timeout(30000) + if (!replaceAssetUid) return this.skip() + + // Confirm pre-replace status exists + const before = await stack.asset(replaceAssetUid).fetch({ include_asset_scan_status: true }) + if (!('_asset_scan_status' in before)) return // Feature not active — skip + + // Replace the file with the same image (triggers a new scan) + try { + await stack.asset(replaceAssetUid).replace({ upload: assetPath }) + await wait(3000) // allow backend to register the new file before fetching + } catch (e) { + // If replace fails (e.g. plan restriction), skip gracefully + console.log(' [scan-test] Replace failed:', e.errorMessage || e.message) + return this.skip() + } + + const after = await stack.asset(replaceAssetUid).fetch({ include_asset_scan_status: true }) + + expect(after).to.have.property('_asset_scan_status') + expect(['pending', 'clean']).to.include(after._asset_scan_status, + `Expected scan status to be pending or clean after replace, got: ${after._asset_scan_status}`) + }) + + // -------------------------------------------------------------------------- + // 10. Folder entries should NOT receive _asset_scan_status (not file assets) + // -------------------------------------------------------------------------- + it('folder entries should NOT have _asset_scan_status (folders are not scannable files)', async function () { + this.timeout(15000) + + const response = await stack.asset().query({ + include_asset_scan_status: true, + include_folders: true, + folder: 'cs_root' + }).find() + + const folders = (response.items || []).filter(item => item.is_dir === true) + if (folders.length === 0) return // No folders to test — pass + + folders.forEach(folder => { + // Folders should NOT have a scan status — they're containers, not content files + expect(folder).to.not.have.property('_asset_scan_status') + }) + }) + + // -------------------------------------------------------------------------- + // 11. Paginated list — status present on all pages + // -------------------------------------------------------------------------- + it('should include _asset_scan_status on items across multiple pages', async function () { + this.timeout(15000) + + const [page1, page2] = await Promise.all([ + stack.asset().query({ include_asset_scan_status: true, limit: 3, skip: 0 }).find(), + stack.asset().query({ include_asset_scan_status: true, limit: 3, skip: 3 }).find() + ]) + + const allItems = [ + ...page1.items.filter(i => !i.is_dir), + ...page2.items.filter(i => !i.is_dir) + ] + + if (allItems.length === 0) return + + // If the feature is active (any item has the field), ALL items must have it + const hasField = allItems.some(i => '_asset_scan_status' in i) + if (!hasField) return // Feature not active — skip silently + + allItems.forEach(item => { + expect(item, `Page item ${item.uid} is missing _asset_scan_status`).to.have.property('_asset_scan_status') + // § 3.6: null is valid for legacy assets + expect(isValidScanStatusOrLegacy(item._asset_scan_status)).to.equal(true, + `Page item ${item.uid} has invalid scan status: ${JSON.stringify(item._asset_scan_status)}`) + }) + }) + + // -------------------------------------------------------------------------- + // 12. include_asset_scan_status=false should behave like omitting it + // -------------------------------------------------------------------------- + it('should NOT return _asset_scan_status when include_asset_scan_status=false', async function () { + this.timeout(15000) + if (!freshAssetUid) return this.skip() + + const asset = await stack.asset(freshAssetUid).fetch({ include_asset_scan_status: false }) + + expect(asset).to.be.an('object') + expect(asset).to.not.have.property('_asset_scan_status') + }) +}) + +// ============================================================================ +// § 3.2 – Upload with include_asset_scan_status param +// Design doc: "When include_asset_scan_status is passed on upload, the response +// immediately includes _asset_scan_status: 'pending'" +// ============================================================================ + +describe('Asset Scan Status – Upload Response (§ 3.2)', () => { + let stack + + before(function () { + const apiKey = process.env.API_KEY + if (!apiKey) return this.skip() + stack = buildStack(apiKey) + }) + + it('should include _asset_scan_status in upload response when param is passed', async function () { + this.timeout(30000) + + // Pass include_asset_scan_status as the second (params) argument to create() + // JS SDK create(): stack.asset().create(assetData, queryParams) + const asset = await stack.asset().create( + { + upload: assetPath, + title: `Scan Upload Param Test ${Date.now()}` + }, + { include_asset_scan_status: true } + ) + + expect(asset).to.be.an('object') + expect(asset.uid).to.be.a('string') + + // The upload response should immediately contain the status field + if ('_asset_scan_status' in asset) { + // A freshly uploaded asset must start as 'pending' (scan queued) + // 'not_scanned' is valid for stacks where scanning is not enabled + expect(['pending', 'not_scanned']).to.include(asset._asset_scan_status, + `Upload response expected pending or not_scanned, got: ${asset._asset_scan_status}`) + } + + // Cleanup + try { await stack.asset(asset.uid).delete() } catch (e) { /* ignore */ } + }) + + it('should NOT include _asset_scan_status in upload response when param is NOT passed', async function () { + this.timeout(30000) + + const asset = await stack.asset().create({ + upload: assetPath, + title: `Scan Upload No Param Test ${Date.now()}` + }) + + expect(asset).to.be.an('object') + expect(asset).to.not.have.property('_asset_scan_status') + + try { await stack.asset(asset.uid).delete() } catch (e) { /* ignore */ } + }) +}) + +// ============================================================================ +// § 3.3 – Download error handling for pending / quarantined assets +// Design doc: +// - Downloading a quarantined asset → HTTP 403 (permanent URL) / 422 (CMA) +// - Error codes surfaced by CMA: asset_scan_pending (422), asset_scan_quarantined (422) +// - SDK must NOT swallow these errors with generic messages +// ============================================================================ + +describe('Asset Scan Status – Download Error Handling (§ 3.3)', () => { + let stack + + before(function () { + const apiKey = process.env.API_KEY + if (!apiKey) return this.skip() + stack = buildStack(apiKey) + }) + + it('should surface asset_scan_pending or asset_scan_quarantined error codes without swallowing them', async function () { + this.timeout(15000) + // We cannot reliably manufacture a quarantined asset in CI, + // so this test verifies the SDK error propagation contract: + // if the API returns 422 with error_code asset_scan_pending/_quarantined, + // the SDK must expose error.status=422 and the original error message — + // NOT replace it with "Session timed out" or a generic SDK message. + + // Use an invalid URL to trigger a predictable SDK error and verify propagation + let errorThrown = false + try { + await stack.asset().download({ url: 'https://invalid-host.example.com/nonexistent-asset', responseType: 'arraybuffer' }) + } catch (err) { + errorThrown = true + // Must expose the real error — not hide it behind a generic wrapper + expect(err.message || err.errorMessage || err.code).to.not.equal(undefined, + 'SDK must surface download errors — error message must not be empty') + if (err.message) { + expect(err.message).to.not.include('Session timed out', + 'SDK must not replace real download errors with generic session timeout message') + } + } + expect(errorThrown).to.equal(true, + 'download() must throw for an invalid host URL — SDK must not silently succeed') + }) + + it('SDK asset.download() must propagate 422 status for scan-blocked downloads', async function () { + this.timeout(15000) + // Verify the SDK does not catch-and-suppress HTTP 4xx during download. + // If asset_scan_pending or asset_scan_quarantined arrives as 422, the SDK + // must re-throw it with status=422 and the API error_code visible to callers. + + // Fetch a real asset then attempt to call download with an injected bad URL + // to confirm the SDK propagates the error with a proper status code. + let assetUid + let errorWasThrown = false + try { + const uploadResp = await stack.asset().create({ + upload: assetPath, + title: `Scan Download Error Test ${Date.now()}` + }) + assetUid = uploadResp.uid + + // Attempt download with a deliberately bad URL (simulates scan-blocked response) + const assetObj = await stack.asset(assetUid).fetch() + await assetObj.download({ url: assetObj.url + '?__scan_error_test=1', responseType: 'arraybuffer' }) + } catch (err) { + errorWasThrown = true + // SDK must not swallow the status code + if (err.status !== undefined) { + expect(err.status).to.be.a('number', + 'Error status must be a number when API returns an HTTP error for scan-blocked download') + } + // Error should carry meaningful information + const hasInfo = err.status || err.errorMessage || err.message || err.code + expect(hasInfo).to.not.equal(undefined, 'SDK must surface scan download errors with context') + } finally { + if (assetUid) { + try { await stack.asset(assetUid).delete() } catch (e) { /* ignore */ } + } + } + // CDN may serve the asset despite the injected query param — explicitly skip + // rather than silently pass so the 422-propagation contract is clearly not exercised. + if (!errorWasThrown) this.skip() + }) +}) + +// ============================================================================ +// Scan Lifecycle – pending → clean / pending → quarantined +// Verifies each distinct status value is reachable and correct. +// Uses a real normal image (→ clean) and EICAR test file (→ quarantined). +// ============================================================================ + +describe('Asset Scan Status – Scan Lifecycle (clean + quarantined)', () => { + let stack + let cleanAssetUid + let eicarAssetUid + let eicarFilePath + + before(function () { + const apiKey = process.env.API_KEY + if (!apiKey) return this.skip() + // Gate behind explicit opt-in: EICAR upload can trigger AV controls in CI environments. + // Set SCAN_LIFECYCLE_TESTS_ENABLED=true in the pipeline/env to enable this suite. + if (!process.env.SCAN_LIFECYCLE_TESTS_ENABLED) { + console.log(' [scan-test] Lifecycle tests skipped — set SCAN_LIFECYCLE_TESTS_ENABLED=true to enable') + return this.skip() + } + stack = buildStack(apiKey) + }) + + before(async function () { + this.timeout(30000) + if (!stack) return + + // Write EICAR test file to a system temp path + try { eicarFilePath = createEicarFile() } catch (e) { + console.log(' [scan-test] EICAR file creation failed:', e.message) + } + + // Upload both assets + cleanAssetUid = await uploadScanAsset(stack, 'lifecycle-clean') + if (eicarFilePath) { + try { + const a = await stack.asset().create({ + upload: eicarFilePath, + title: `Scan Test EICAR ${Date.now()}`, + description: 'EICAR antivirus test file for quarantine status verification' + }) + eicarAssetUid = a.uid + } catch (e) { + console.log(' [scan-test] EICAR upload failed:', e.errorMessage || e.message) + } + } + console.log(` [scan-test] Lifecycle: cleanUid=${cleanAssetUid} eicarUid=${eicarAssetUid}`) + }) + + after(async function () { + for (const uid of [cleanAssetUid, eicarAssetUid]) { + if (uid && stack) try { await stack.asset(uid).delete() } catch (e) {} + } + if (eicarFilePath) try { fs.unlinkSync(eicarFilePath) } catch (e) {} + }) + + it('clean image should transition from pending to clean after scan completes', async function () { + this.timeout(90000) + if (!cleanAssetUid) return this.skip() + + const status = await waitForScan(stack, cleanAssetUid, 'clean') + if (status === 'not_scanned') { + console.log(' [scan-test] Scanning not enabled on this stack (not_scanned) — skipping clean assertion') + return + } + expect(status).to.equal('clean', + `Expected normal image to scan as 'clean', got: ${JSON.stringify(status)}`) + }) + + it('EICAR antivirus test file should reach quarantined status after scan', async function () { + this.timeout(90000) + if (!eicarAssetUid) return this.skip() + + const status = await waitForScan(stack, eicarAssetUid, 'quarantined') + if (status === 'not_scanned') { + console.log(' [scan-test] Scanning not enabled on this stack (not_scanned) — skipping quarantine assertion') + return + } + expect(status).to.equal('quarantined', + `Expected EICAR file to be quarantined, got: ${JSON.stringify(status)}`) + }) + + it('quarantined asset download should be blocked with scan error (§ 3.3 real asset)', async function () { + this.timeout(90000) + if (!eicarAssetUid) return this.skip() + + // Only run once the asset is confirmed quarantined + const status = await waitForScan(stack, eicarAssetUid, 'quarantined') + if (status !== 'quarantined') return this.skip() + + try { + const asset = await stack.asset(eicarAssetUid).fetch({ include_asset_scan_status: true }) + // Attempt to download — should be blocked for quarantined assets + await stack.asset().download({ url: asset.url, responseType: 'arraybuffer' }) + } catch (err) { + const httpStatus = err.status || (err.response && err.response.status) + const errCode = String( + err.errorCode || err.error_code || + (err.response && err.response.data && err.response.data.error_code) || '' + ) + // § 3.3: blocked download must surface 403 or 422, not a generic/swallowed error + if (httpStatus) { + expect([403, 422]).to.include(httpStatus, + `Quarantined asset download must return 403 or 422, got: ${httpStatus}`) + } + if (errCode) { + expect(['asset_scan_quarantined', 'access_denied']).to.include(errCode, + `Expected scan-related error code, got: ${errCode}`) + } + } + }) +}) + +// ============================================================================ +// § 3.4 – Single asset publish: scan validation is ASYNC, not blocking +// Design doc: "The publish API always returns 'Asset sent for publishing' — +// there is no synchronous error for quarantined assets. Publish queue fails +// asynchronously. Requires api_version: 3.2 header for CDX scan validation." +// ============================================================================ + +describe('Asset Scan Status – Publish Is Always Async (§ 3.4)', () => { + let stack + let publishAssetUid + let publishEnvironment + let createdEnvironmentName = null // track if we created it so we can delete it + + before(function () { + const apiKey = process.env.API_KEY + if (!apiKey) return this.skip() + stack = buildStack(apiKey) + }) + + before(async function () { + this.timeout(60000) + + // Prefer an environment already created by prior test phases + publishEnvironment = (testData.environments && testData.environments.development) + ? testData.environments.development.name + : null + + // Try querying for an existing one + if (!publishEnvironment) { + try { + const envResp = await stack.environment().query().find() + if (envResp.items && envResp.items.length > 0) { + publishEnvironment = envResp.items[0].name + } + } catch (e) { /* ignore */ } + } + + // Nothing found — create a temporary environment for this test + if (!publishEnvironment) { + try { + const envName = `scan-publish-env-${Math.random().toString(36).substring(2, 7)}` + await stack.environment().create({ + environment: { + name: envName, + urls: [{ locale: 'en-us', url: 'http://localhost' }] + } + }) + publishEnvironment = envName + createdEnvironmentName = envName + console.log(` [scan-test] Created temporary publish environment: ${envName}`) + } catch (e) { + console.log(' [scan-test] Could not create environment:', e.errorMessage || e.message) + } + } + + // Upload a fresh asset for publish tests + try { + const asset = await stack.asset().create({ + upload: assetPath, + title: `Scan Publish Test ${Date.now()}` + }) + publishAssetUid = asset.uid + await wait(2000) + } catch (e) { + console.log(' [scan-test] publish test asset upload failed:', e.errorMessage || e.message) + } + }) + + after(async function () { + if (publishAssetUid) { + try { await stack.asset(publishAssetUid).delete() } catch (e) { /* ignore */ } + } + if (createdEnvironmentName) { + try { await stack.environment(createdEnvironmentName).delete() } catch (e) { /* ignore */ } + } + }) + + it('should return success notice on publish regardless of scan status (async validation)', async function () { + this.timeout(30000) + if (!publishAssetUid || !publishEnvironment) return this.skip() + + // § 3.4: publish MUST succeed synchronously even if asset is pending/quarantined. + // Scan enforcement happens asynchronously in the Publish Queue. + try { + const asset = await stack.asset(publishAssetUid).fetch() + const response = await asset.publish({ + publishDetails: { + environments: [publishEnvironment], + locales: ['en-us'] + } + }) + // API must return a success notice — NOT a scan-related error + expect(response.notice).to.be.a('string', + 'Publish must return a notice string — scan validation is async, not a sync blocker') + } catch (err) { + // If publish throws, the error must NOT be a scan-specific error (scan is async) + const errMsg = (err.errorMessage || err.message || '').toLowerCase() + expect(errMsg).to.not.include('scan', + `Publish threw a scan-related error — this must not happen synchronously: ${errMsg}`) + expect(errMsg).to.not.include('quarantine', + `Publish threw a quarantine error — this must not happen synchronously: ${errMsg}`) + } + }) + + it('should NOT return asset_scan_quarantined error synchronously during publish', async function () { + this.timeout(15000) + if (!publishAssetUid || !publishEnvironment) return this.skip() + + // Verify that the publish SDK method never rejects with scan-related error codes. + // If it does, that is a bug — scan rejection belongs in the async Publish Queue. + try { + const asset = await stack.asset(publishAssetUid).fetch() + await asset.publish({ + publishDetails: { + environments: [publishEnvironment], + locales: ['en-us'] + } + }) + // Reaching here means publish succeeded — which is the correct behavior + } catch (err) { + const code = err.errorCode || err.error_code || '' + expect(String(code)).to.not.include('scan', + `Publish returned scan error code synchronously: ${code}`) + } + }) +}) + +// ============================================================================ +// § 3.6 – Legacy asset null status +// Design doc: "Assets uploaded before scanning was enabled will have +// _asset_scan_status = null when include_asset_scan_status is passed." +// The SDK must handle null without crashing or treating it as an error. +// ============================================================================ + +describe('Asset Scan Status – Legacy Asset Null Handling (§ 3.6)', () => { + let stack + + before(function () { + const apiKey = process.env.API_KEY + if (!apiKey) return this.skip() + stack = buildStack(apiKey) + }) + + it('should accept null as a valid _asset_scan_status value for legacy assets', async function () { + this.timeout(15000) + // Fetch all assets with scan param — on orgs with pre-scan legacy assets, + // some items may have _asset_scan_status: null. + const response = await stack.asset().query({ include_asset_scan_status: true, limit: 50 }).find() + + expect(response).to.be.an('object') + expect(response.items).to.be.an('array') + + response.items.filter(i => !i.is_dir).forEach(item => { + if ('_asset_scan_status' in item) { + // null (legacy) OR a valid enum string are both acceptable + expect(isValidScanStatusOrLegacy(item._asset_scan_status)).to.equal(true, + `Asset ${item.uid} has unexpected _asset_scan_status: ${JSON.stringify(item._asset_scan_status)}`) + // SDK must never produce undefined — that would be a SDK transformation bug + expect(item._asset_scan_status).to.not.equal(undefined, + `Asset ${item.uid} _asset_scan_status is undefined — SDK must preserve null from API response`) + } + }) + }) + + it('should handle null scan status gracefully on single asset fetch', async function () { + this.timeout(15000) + // Find any asset that may have null scan status and verify no SDK crash + const response = await stack.asset().query({ include_asset_scan_status: true, limit: 20 }).find() + const nullStatusAsset = (response.items || []).find(i => i._asset_scan_status === null) + + if (!nullStatusAsset) { + // No legacy assets found — this is fine on fresh stacks. Pass. + return + } + + // Verify individual fetch also returns null (not converts to something else) + const single = await stack.asset(nullStatusAsset.uid).fetch({ include_asset_scan_status: true }) + expect(single._asset_scan_status === null || VALID_SCAN_STATUSES.includes(single._asset_scan_status)).to.equal(true, + `Legacy asset ${nullStatusAsset.uid} returned unexpected status: ${JSON.stringify(single._asset_scan_status)}`) + }) +}) + +// ============================================================================ +// § 4.2 – api_version: 3.2 header must NOT bleed into non-publish API calls +// Design doc: "Edge Case — if api_version header is set as a global SDK header, +// it would affect all API calls and break other functionality." +// After calling bulkOperation.publish({ api_version: '3.2' }), subsequent +// content type / entry / asset fetches must NOT carry the api_version header. +// ============================================================================ + +describe('Asset Scan Status – api_version Header Isolation (§ 4.2)', () => { + let stack + + before(function () { + const ctx = testSetup.testContext + if (!ctx || !ctx.stackApiKey) return this.skip() + stack = buildStack(ctx.stackApiKey) + }) + + it('asset fetch after bulkOperation.publish() must NOT carry api_version: 3.2 header', async function () { + this.timeout(30000) + + // Perform a bulk publish with api_version (typical call pattern) + try { + const ctx = testSetup.testContext + const client = contentstackClient() + const bulkStack = client.stack({ api_key: ctx.stackApiKey }) + + await bulkStack.bulkOperation().publish({ + details: { entries: [], assets: [], locales: ['en-us'], environments: [] }, + api_version: '3.2' + }) + } catch (e) { + // Bulk publish may fail (no items) — that's OK, we just want the side effect + } + + // Now perform a plain asset fetch and verify api_version is NOT present in headers + // The SDK captures requests via the plugin — we check the captured headers + const ctx = testSetup.testContext + if (ctx && ctx.capturedRequests) { + // Trigger a real fetch to capture the request + await stack.asset().query({ limit: 1 }).find() + + const lastReq = ctx.capturedRequests[ctx.capturedRequests.length - 1] + if (lastReq && lastReq.headers) { + expect(lastReq.headers).to.not.have.property('api_version') + } + } + + // Functional check: asset fetch must work normally after bulk publish with api_version + const response = await stack.asset().query({ limit: 1 }).find() + expect(response).to.be.an('object') + expect(response.items).to.be.an('array') + // If api_version: 3.2 bled into the fetch, certain list endpoints may return different + // response shapes — verifying items array means basic fetch still works correctly + }) + + it('content type fetch must work normally after bulk publish with api_version header', async function () { + this.timeout(15000) + // Regression guard: api_version: 3.2 applied globally could change content type + // response schema (api_version 3.2 returns nested fields differently). + // Verify content type list is still a normal response after bulk publish call. + const response = await stack.contentType().query().find() + + expect(response).to.be.an('object') + const cts = response.content_types || response.items || [] + expect(cts).to.be.an('array', + 'Content type list must return a normal array — api_version header must not bleed from bulk publish') + }) +}) + +// ============================================================================ +// Part 2 – AM Org (AM_ORG_UID – DAM / Contentstack Assets + scan enabled) +// +// The AM org stack is created dynamically using the same authtoken that the +// main test suite already obtained — no static AM_API_KEY needed in .env. +// The stack is deleted in the after() hook. +// ============================================================================ + +describe('Asset Scan Status – AM Org (AM_ORG_UID, DAM + scan enabled)', function () { + let amStack + let amFreshAssetUid + let amReplaceAssetUid + let amStackApiKey = null + let amStackName = null + + // Step 1: Create a stack dynamically inside AM_ORG_UID + before(async function () { + this.timeout(60000) + + const amOrgUid = process.env.AM_ORG_UID + if (!amOrgUid) { + console.log(' [scan-test] AM_ORG_UID not set — skipping AM Org suite.') + return this.skip() + } + + const authtoken = testSetup.testContext && testSetup.testContext.authtoken + if (!authtoken) { + console.log(' [scan-test] No authtoken in testContext — skipping AM Org suite.') + return this.skip() + } + + const host = process.env.HOST || 'api.contentstack.io' + const axios = (await import('axios')).default + const stackName = `SDK_ScanAM_${Math.random().toString(36).substring(2, 7)}` + + console.log(` [scan-test] Creating AM org test stack: ${stackName}...`) + + try { + const response = await axios.post(`https://${host}/v3/stacks`, { + stack: { + name: stackName, + description: 'AM org asset scan status integration test stack', + master_locale: 'en-us' + } + }, { + headers: { + authtoken, + organization_uid: amOrgUid, + 'Content-Type': 'application/json' + } + }) + + amStackApiKey = response.data.stack.api_key + amStackName = response.data.stack.name || stackName + console.log(` [scan-test] AM stack created: ${amStackName} (${amStackApiKey})`) + + // Wait for stack provisioning (same delay as main setup) + await wait(5000) + + amStack = buildStack(amStackApiKey) + } catch (err) { + const msg = (err.response && err.response.data && err.response.data.error_message) || err.message + console.log(` [scan-test] AM stack creation failed: ${msg} — skipping AM Org suite.`) + return this.skip() + } + }) + + // Step 2: Upload assets for tests (only runs if step 1 succeeded) + before(async function () { + this.timeout(60000) + if (!amStack) return + amFreshAssetUid = await uploadScanAsset(amStack, 'am-main') + amReplaceAssetUid = await uploadScanAsset(amStack, 'am-replace') + console.log(` [scan-test] AM freshAssetUid=${amFreshAssetUid} replaceAssetUid=${amReplaceAssetUid}`) + }) + + // Cleanup: delete assets, then delete the dynamically created AM stack + after(async function () { + this.timeout(30000) + + for (const uid of [amFreshAssetUid, amReplaceAssetUid]) { + if (uid && amStack) { + try { await amStack.asset(uid).delete() } catch (e) { /* ignore */ } + } + } + + if (amStackApiKey) { + try { + const authtoken = testSetup.testContext && testSetup.testContext.authtoken + if (authtoken) { + const host = process.env.HOST || 'api.contentstack.io' + const axios = (await import('axios')).default + await axios.delete(`https://${host}/v3/stacks`, { + headers: { api_key: amStackApiKey, authtoken } + }) + console.log(` [scan-test] Deleted AM stack: ${amStackName}`) + } + } catch (e) { + console.log(` [scan-test] AM stack deletion failed: ${e.message}`) + } + } + }) + + // -------------------------------------------------------------------------- + // AM-1. Upload on AM org → status present and valid enum + // -------------------------------------------------------------------------- + it('[AM] should return _asset_scan_status for freshly uploaded DAM asset', async function () { + this.timeout(15000) + if (!amFreshAssetUid) return this.skip() + + const asset = await amStack.asset(amFreshAssetUid).fetch({ include_asset_scan_status: true }) + + expect(asset).to.be.an('object') + expect(asset.uid).to.equal(amFreshAssetUid) + + if ('_asset_scan_status' in asset) { + expect(asset._asset_scan_status).to.be.a('string') + expect(VALID_SCAN_STATUSES).to.include(asset._asset_scan_status) + // DAM assets go through a different ingestion pipeline — status must never be empty/null + expect(asset._asset_scan_status.trim().length).to.be.greaterThan(0) + } + }) + + // -------------------------------------------------------------------------- + // AM-2. Status absent without param on AM stack + // -------------------------------------------------------------------------- + it('[AM] should NOT return _asset_scan_status when param is omitted on AM stack', async function () { + this.timeout(15000) + if (!amFreshAssetUid) return this.skip() + + const asset = await amStack.asset(amFreshAssetUid).fetch() + + expect(asset).to.be.an('object') + expect(asset).to.not.have.property('_asset_scan_status') + }) + + // -------------------------------------------------------------------------- + // AM-3. List query on AM stack — all file items have status when requested + // -------------------------------------------------------------------------- + it('[AM] should include _asset_scan_status on all file items in AM list query', async function () { + this.timeout(15000) + + const response = await amStack.asset().query({ include_asset_scan_status: true, limit: 10 }).find() + + expect(response).to.be.an('object') + expect(response.items).to.be.an('array') + + const fileItems = (response.items || []).filter(i => !i.is_dir) + if (fileItems.length === 0) return + + const hasField = fileItems.some(i => '_asset_scan_status' in i) + if (!hasField) return // Feature not active on this AM stack + + fileItems.forEach(item => { + expect(item).to.have.property('_asset_scan_status') + expect(VALID_SCAN_STATUSES).to.include(item._asset_scan_status) + }) + }) + + // -------------------------------------------------------------------------- + // AM-4. Status consistent between single fetch and list on AM stack + // -------------------------------------------------------------------------- + it('[AM] scan status should be consistent between single fetch and list query on AM stack', async function () { + this.timeout(15000) + if (!amFreshAssetUid) return this.skip() + + const [single, list] = await Promise.all([ + amStack.asset(amFreshAssetUid).fetch({ include_asset_scan_status: true }), + amStack.asset().query({ include_asset_scan_status: true, limit: 100 }).find() + ]) + + if (!single._asset_scan_status) return + + const listItem = list.items.find(a => a.uid === amFreshAssetUid) + if (!listItem) return + + expect(listItem._asset_scan_status).to.equal(single._asset_scan_status, + `AM inconsistency — fetch: "${single._asset_scan_status}", list: "${listItem._asset_scan_status}"`) + }) + + // -------------------------------------------------------------------------- + // AM-5. After replace on AM stack → status resets to pending + // -------------------------------------------------------------------------- + it('[AM] should reset scan status to pending after file replace on AM stack', async function () { + this.timeout(30000) + if (!amReplaceAssetUid) return this.skip() + + const before = await amStack.asset(amReplaceAssetUid).fetch({ include_asset_scan_status: true }) + if (!('_asset_scan_status' in before)) return + + try { + await amStack.asset(amReplaceAssetUid).replace({ upload: assetPath }) + await wait(3000) + } catch (e) { + console.log(' [scan-test] AM replace failed:', e.errorMessage || e.message) + return this.skip() + } + + const after = await amStack.asset(amReplaceAssetUid).fetch({ include_asset_scan_status: true }) + + expect(after).to.have.property('_asset_scan_status') + expect(['pending', 'clean']).to.include(after._asset_scan_status, + `Expected pending or clean after AM replace, got: ${after._asset_scan_status}`) + }) + + // -------------------------------------------------------------------------- + // AM-6. Combined params on AM stack — locale + scan status + // -------------------------------------------------------------------------- + it('[AM] should include scan status when combined with locale param on AM stack', async function () { + this.timeout(15000) + if (!amFreshAssetUid) return this.skip() + + const asset = await amStack.asset(amFreshAssetUid).fetch({ + include_asset_scan_status: true, + locale: 'en-us' + }) + + expect(asset).to.be.an('object') + if ('_asset_scan_status' in asset) { + expect(VALID_SCAN_STATUSES).to.include(asset._asset_scan_status) + } + }) + + // -------------------------------------------------------------------------- + // AM-7. Status with version param on AM stack + // -------------------------------------------------------------------------- + it('[AM] should include scan status when combined with version=1 param on AM stack', async function () { + this.timeout(15000) + if (!amFreshAssetUid) return this.skip() + + const asset = await amStack.asset(amFreshAssetUid).fetch({ + include_asset_scan_status: true, + version: 1 + }) + + expect(asset).to.be.an('object') + if ('_asset_scan_status' in asset) { + expect(VALID_SCAN_STATUSES).to.include(asset._asset_scan_status) + } + }) + + // -------------------------------------------------------------------------- + // AM-8. Clean image transitions pending → clean on AM (DAM) stack + // -------------------------------------------------------------------------- + it('[AM] clean image should transition from pending to clean after scan', async function () { + this.timeout(90000) + if (!amFreshAssetUid) return this.skip() + + const status = await waitForScan(amStack, amFreshAssetUid, 'clean') + if (status === 'not_scanned') { + console.log(' [scan-test] AM: scanning not enabled on this stack — skipping clean assertion') + return + } + expect(status).to.equal('clean', + `AM clean image expected 'clean' after scan, got: ${JSON.stringify(status)}`) + }) + + // -------------------------------------------------------------------------- + // AM-9. EICAR file reaches quarantined on AM (DAM) stack + // -------------------------------------------------------------------------- + it('[AM] EICAR antivirus test file should reach quarantined status on AM stack', async function () { + this.timeout(90000) + if (!amStack) return this.skip() + + let eicarPath + let eicarUid + try { + eicarPath = createEicarFile() + const a = await amStack.asset().create({ + upload: eicarPath, + title: `AM EICAR Test ${Date.now()}`, + description: 'EICAR antivirus test file for quarantine verification on AM stack' + }) + eicarUid = a.uid + } catch (e) { + console.log(' [scan-test] AM EICAR upload failed:', e.errorMessage || e.message) + return this.skip() + } finally { + if (eicarPath) try { fs.unlinkSync(eicarPath) } catch (e) {} + } + + try { + const status = await waitForScan(amStack, eicarUid, 'quarantined') + if (status === 'not_scanned') { + console.log(' [scan-test] AM: scanning not enabled — skipping quarantine assertion') + return + } + expect(status).to.equal('quarantined', + `AM EICAR file expected 'quarantined', got: ${JSON.stringify(status)}`) + } finally { + if (eicarUid) try { await amStack.asset(eicarUid).delete() } catch (e) {} + } + }) +}) diff --git a/test/sanity-check/api/bulkOperation-test.js b/test/sanity-check/api/bulkOperation-test.js index af4a5270..1d50d44a 100644 --- a/test/sanity-check/api/bulkOperation-test.js +++ b/test/sanity-check/api/bulkOperation-test.js @@ -40,7 +40,7 @@ function assetsWithValidUids () { return [assetUid1, assetUid2].filter(uid => uid && String(uid).trim()).map(uid => ({ uid })) } -async function waitForJobReady (jobId, maxAttempts = 3) { +async function waitForJobReady (jobId, maxAttempts = 15) { for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { // GET /v3/bulk/jobs/{job_id} on AM2.0 requires management token auth (authtoken returns 401) @@ -369,6 +369,7 @@ describe('BulkOperation api test', () => { it('should wait for jobs to be ready and get job status for the first publish job', async function () { this.timeout(60000) + this.retries(2) const response = await waitForJobReady(jobId1) expect(response).to.not.equal(undefined) @@ -381,6 +382,7 @@ describe('BulkOperation api test', () => { it('should validate detailed job status response structure', async function () { this.timeout(60000) + this.retries(2) const response = await waitForJobReady(jobId1) expect(response).to.not.equal(undefined) expect(response.uid).to.not.equal(undefined) @@ -394,6 +396,7 @@ describe('BulkOperation api test', () => { it('should get job status for the second publish job', async function () { this.timeout(60000) + this.retries(2) const response = await waitForJobReady(jobId2) expect(response).to.not.equal(undefined) @@ -406,6 +409,7 @@ describe('BulkOperation api test', () => { it('should get job status for the third publish job', async function () { this.timeout(60000) + this.retries(2) const response = await waitForJobReady(jobId3) expect(response).to.not.equal(undefined) @@ -418,6 +422,7 @@ describe('BulkOperation api test', () => { it('should get job status for publishAllLocalized=true job', async function () { this.timeout(60000) + this.retries(2) const response = await waitForJobReady(jobId4) expect(response).to.not.equal(undefined) @@ -430,6 +435,7 @@ describe('BulkOperation api test', () => { it('should get job status for publishAllLocalized=false job', async function () { this.timeout(60000) + this.retries(2) const response = await waitForJobReady(jobId5) expect(response).to.not.equal(undefined) @@ -442,6 +448,7 @@ describe('BulkOperation api test', () => { it('should get job status for asset publishAllLocalized job', async function () { this.timeout(60000) + this.retries(2) const response = await waitForJobReady(jobId6) expect(response).to.not.equal(undefined) @@ -454,6 +461,7 @@ describe('BulkOperation api test', () => { it('should get job status for unpublishAllLocalized=true job', async function () { this.timeout(60000) + this.retries(2) const response = await waitForJobReady(jobId7) expect(response).to.not.equal(undefined) @@ -466,6 +474,7 @@ describe('BulkOperation api test', () => { it('should get job status for unpublishAllLocalized=false job', async function () { this.timeout(60000) + this.retries(2) const response = await waitForJobReady(jobId8) expect(response).to.not.equal(undefined) @@ -478,6 +487,7 @@ describe('BulkOperation api test', () => { it('should get job status for asset unpublishAllLocalized job', async function () { this.timeout(60000) + this.retries(2) const response = await waitForJobReady(jobId9) expect(response).to.not.equal(undefined) @@ -490,6 +500,7 @@ describe('BulkOperation api test', () => { it('should get job status for multiple parameters job', async function () { this.timeout(60000) + this.retries(2) const response = await waitForJobReady(jobId10) expect(response).to.not.equal(undefined) @@ -502,6 +513,7 @@ describe('BulkOperation api test', () => { it('should get job status with bulk_version parameter', async function () { this.timeout(60000) + this.retries(2) await waitForJobReady(jobId1) const response = await doBulkOperationWithManagementToken(tokenUidDev) @@ -516,6 +528,7 @@ describe('BulkOperation api test', () => { it('should get job items for a completed job', async function () { this.timeout(60000) + this.retries(2) await waitForJobReady(jobId1) const response = await doBulkOperationWithManagementToken(tokenUidDev) @@ -526,6 +539,7 @@ describe('BulkOperation api test', () => { it('should get job items with explicit api_version', async function () { this.timeout(60000) + this.retries(2) await waitForJobReady(jobId2) const response = await doBulkOperationWithManagementToken(tokenUidDev) diff --git a/test/sanity-check/api/entryVariants-test.js b/test/sanity-check/api/entryVariants-test.js index 3b3d1194..e46d955c 100644 --- a/test/sanity-check/api/entryVariants-test.js +++ b/test/sanity-check/api/entryVariants-test.js @@ -259,6 +259,159 @@ describe('Entry Variants API Tests', () => { }) }) + /** + * SDK: .variants(variantUidOrUids, branchName?) — optional branch sent as the `branch` header + * for this variants scope (stack default branch when omitted). + * Uses `main` when the Branch API is available; skips if branches are not enabled on the stack. + */ + describe('Entry Variants with explicit branch (second argument)', () => { + let branchForVariants = null + + before(async function () { + this.timeout(30000) + try { + const mainBranch = await stack.branch('main').fetch() + if (mainBranch && mainBranch.uid) { + branchForVariants = mainBranch.uid + } + } catch (e) { + console.log(' Entry Variants branch tests: could not resolve main branch —', e.errorMessage || e.message) + branchForVariants = null + } + }) + + it('should fetch entry variant with variants(uid, branch)', async function () { + this.timeout(20000) + + if (!branchForVariants || !contentTypeUid || !entryUid || !variantUid) { + this.skip() + } + + try { + const response = await stack + .contentType(contentTypeUid) + .entry(entryUid) + .variants(variantUid, branchForVariants) + .fetch() + + trackedExpect(response, 'Entry variant fetch (with branch)').toBeAn('object') + trackedExpect(response.entry, 'Entry variant entry').toExist() + trackedExpect(response.entry._variant, 'Entry variant _variant').toExist() + } catch (error) { + if (error.status === 403 || error.status === 404 || error.status === 422) { + this.skip() + } + throw error + } + }) + + it('should update entry variant with variants(uid, branch)', async function () { + this.timeout(20000) + + if (!branchForVariants || !contentTypeUid || !entryUid || !variantUid) { + this.skip() + } + + const variantEntryData = { + entry: { + title: `Branch arg variant ${generateUniqueId()}`, + _variant: { + _change_set: ['title'] + } + } + } + + try { + const response = await stack + .contentType(contentTypeUid) + .entry(entryUid) + .variants(variantUid, branchForVariants) + .update(variantEntryData) + + trackedExpect(response, 'Entry variant update (with branch)').toBeAn('object') + trackedExpect(response.entry, 'Entry variant entry').toExist() + trackedExpect(response.entry.title, 'Entry variant title').toExist() + } catch (error) { + if (error.status === 403 || error.status === 422 || error.status === 412) { + this.skip() + } + throw error + } + }) + + it('should list entry variants with variants(null, branch).query().find()', async function () { + this.timeout(20000) + + if (!branchForVariants || !contentTypeUid || !entryUid) { + this.skip() + } + + try { + const response = await stack + .contentType(contentTypeUid) + .entry(entryUid) + .variants(null, branchForVariants) + .query({}) + .find() + + expect(response.items).to.be.an('array') + } catch (error) { + if (error.status === 403 || error.status === 404) { + this.skip() + } + throw error + } + }) + + it('should fetch variant versions with variants(uid, branch).versions()', async function () { + this.timeout(20000) + + if (!branchForVariants || !contentTypeUid || !entryUid || !variantUid) { + this.skip() + } + + try { + const response = await stack + .contentType(contentTypeUid) + .entry(entryUid) + .variants(variantUid, branchForVariants) + .versions() + + expect(response).to.be.an('object') + expect(response.versions).to.be.an('array') + } catch (error) { + if (error.status === 403 || error.status === 404 || error.status === 422) { + this.skip() + } + throw error + } + }) + + it('should fetch entry variant with variants([uid], branch) (single-element array)', async function () { + this.timeout(20000) + + if (!branchForVariants || !contentTypeUid || !entryUid || !variantUid) { + this.skip() + } + + try { + const response = await stack + .contentType(contentTypeUid) + .entry(entryUid) + .variants([variantUid], branchForVariants) + .fetch() + + trackedExpect(response.entry, 'Entry variant entry (array uid)').toExist() + trackedExpect(response.entry._variant, 'Entry variant _variant').toExist() + } catch (error) { + if (error.status === 403 || error.status === 404 || error.status === 422) { + this.skip() + } + throw error + } + }) + }) + describe('Entry Variant Publishing', () => { it('should publish entry variant', async function () { this.timeout(15000) @@ -299,6 +452,46 @@ describe('Entry Variants API Tests', () => { } }) + it('should publish entry variant via variants(uid).publish()', async function () { + this.timeout(15000) + + if (!contentTypeUid || !entryUid || !variantUid) { + this.skip() + } + + const publishDetails = { + environments: [environmentName], + locales: ['en-us'], + variants: [{ + uid: variantUid, + version: 1 + }], + variant_rules: { + publish_latest_base: false, + publish_latest_base_conditionally: true + } + } + + try { + const response = await stack + .contentType(contentTypeUid) + .entry(entryUid) + .variants(variantUid) + .publish({ + publishDetails, + locale: 'en-us' + }) + + expect(response.notice).to.not.equal(undefined) + } catch (error) { + if (error.status === 403 || error.status === 422) { + this.skip() + } else { + console.log('variants().publish warning:', error.message) + } + } + }) + it('should publish entry variant with api_version', async function () { this.timeout(15000) @@ -368,6 +561,42 @@ describe('Entry Variants API Tests', () => { } } }) + + it('should unpublish entry variant via variants(uid).unpublish()', async function () { + this.timeout(15000) + + if (!contentTypeUid || !entryUid || !variantUid) { + this.skip() + } + + const unpublishDetails = { + environments: [environmentName], + locales: ['en-us'], + variants: [{ + uid: variantUid, + version: 1 + }] + } + + try { + const response = await stack + .contentType(contentTypeUid) + .entry(entryUid) + .variants(variantUid) + .unpublish({ + publishDetails: unpublishDetails, + locale: 'en-us' + }) + + expect(response.notice).to.not.equal(undefined) + } catch (error) { + if (error.status === 403 || error.status === 422) { + this.skip() + } else { + console.log('variants().unpublish warning:', error.message) + } + } + }) }) describe('Entry Variant Deletion', () => { diff --git a/test/sanity-check/sanity.js b/test/sanity-check/sanity.js index 91cf9b4a..df5bffcb 100644 --- a/test/sanity-check/sanity.js +++ b/test/sanity-check/sanity.js @@ -81,6 +81,10 @@ import './api/environment-test.js' // Phase 6: Assets (needed for entries with file fields) import './api/asset-test.js' +// Phase 6.5: Asset Scan Status - comprehensive tests for include_asset_scan_status param +// Covers both ORGANIZATION stack (non-AM, scan enabled) and AM_ORG_UID stack (dynamically created, no static key needed) +import './api/assetScanStatus-test.js' + // Phase 7: Taxonomies (needed for content types with taxonomy fields) import './api/taxonomy-test.js' import './api/terms-test.js' @@ -238,8 +242,17 @@ before(async function () { // GLOBAL CURL CAPTURE FOR ALL TESTS (PASSED AND FAILED) // ============================================================================ -// Clear request log and assertion tracker before each test -beforeEach(function () { +// Clear request log and assertion tracker before each test. +// When a test has this.retries(N) and is being retried, waits 7 seconds first +// so the server has time to recover from slowness before the next attempt. +beforeEach(async function () { + const retryNum = (typeof this.currentRetry === 'function') ? this.currentRetry() : 0 + if (retryNum > 0) { + const testTitle = (this.currentTest && this.currentTest.title) || 'unknown' + console.log(` [retry] attempt ${retryNum + 1} for "${testTitle}" — waiting 7s for server recovery...`) + await new Promise(resolve => setTimeout(resolve, 7000)) + } + // Clear SDK plugin request capture testSetup.clearCapturedRequests() diff --git a/test/unit/variants-entry-test.js b/test/unit/variants-entry-test.js index 69589873..3688770e 100644 --- a/test/unit/variants-entry-test.js +++ b/test/unit/variants-entry-test.js @@ -238,6 +238,155 @@ describe('Contentstack Variants entry test', () => { }) .catch(done) }) + + it('Variants entry publish posts to entry publish URL with optional headers and params', (done) => { + const mock = new MockAdapter(Axios) + mock.onPost('/content_types/content_type_uid/entries/entry_uid/publish').reply((config) => { + const body = typeof config.data === 'string' ? JSON.parse(config.data) : config.data + expect(body.entry).to.be.an('object') + expect(body.entry.variants).to.be.an('array') + expect(body.entry.variants[0].uid).to.equal('variant_uid') + expect(body.locale).to.equal('en-us') + expect(config.headers['x-custom']).to.equal('1') + expect(config.params.t).to.equal('1') + return [200, { notice: 'Entry sent for publishing.', job_id: 'jid' }] + }) + makeEntryVariants({ + content_type_uid: 'content_type_uid', + entry_uid: 'entry_uid', + variants_uid: 'variant_uid', + stackHeaders: { api_key: 'k' } + }) + .publish({ + publishDetails: { + environments: ['development'], + locales: ['en-us'], + variants: [{ uid: 'variant_uid', version: 1 }], + variant_rules: { publish_latest_base_conditionally: true } + }, + locale: 'en-us', + headers: { 'x-custom': '1' }, + params: { t: '1' } + }) + .then((res) => { + expect(res.notice).to.include('publish') + done() + }) + .catch(done) + }) + + it('Variants entry unpublish posts to entry unpublish URL with optional headers and params', (done) => { + const mock = new MockAdapter(Axios) + mock.onPost('/content_types/content_type_uid/entries/entry_uid/unpublish').reply((config) => { + const body = typeof config.data === 'string' ? JSON.parse(config.data) : config.data + expect(body.entry).to.be.an('object') + expect(body.entry.variants).to.be.an('array') + expect(body.locale).to.equal('en-us') + expect(config.headers['x-unpub']).to.equal('y') + expect(config.params.q).to.equal('2') + return [200, { notice: 'Entry sent for unpublishing.' }] + }) + makeEntryVariants({ + content_type_uid: 'content_type_uid', + entry_uid: 'entry_uid', + variants_uid: 'variant_uid', + stackHeaders: { api_key: 'k' } + }) + .unpublish({ + publishDetails: { + environments: ['development'], + locales: ['en-us'], + variants: [{ uid: 'variant_uid', version: 1 }] + }, + locale: 'en-us', + headers: { 'x-unpub': 'y' }, + params: { q: '2' } + }) + .then((res) => { + expect(res.notice).to.include('unpublish') + done() + }) + .catch(done) + }) + + it('Entry variants fetch sends optional branch header', (done) => { + const mock = new MockAdapter(Axios) + mock + .onGet('/content_types/content_type_uid/entries/UID/variants/v1') + .reply((config) => { + expect(config.headers.branch).to.equal('feature_branch') + return [200, { + entry: { + ...varinatsEntryMock + } + }] + }) + makeEntry({ + entry: { ...systemUidMock }, + stackHeaders: { api_key: 'test_key' } + }) + .variants('v1', 'feature_branch') + .fetch() + .then((entry) => { + checkEntry(entry.entry) + done() + }) + .catch(done) + }) + + it('Entry variants multiple UIDs and branch on update', (done) => { + const mock = new MockAdapter(Axios) + mock + .onPut('/content_types/content_type_uid/entries/entry_uid/variants/u1,u2') + .reply((config) => { + expect(config.headers.branch).to.equal('devel') + return [200, { + entry: { + title: 'ok', + uid: 'variant_uid', + content_type: 'content_type_uid', + locale: 'en-us', + _version: 1, + _in_progress: false + } + }] + }) + makeEntry({ + entry: { ...systemUidMock, uid: 'entry_uid' }, + stackHeaders: { api_key: 'k' } + }) + .variants(['u1', 'u2'], 'devel') + .update({ entry: { title: 'x' } }) + .then((response) => { + expect(response.entry.title).to.be.equal('ok') + done() + }) + .catch(done) + }) + + it('Entry variants query mode with branch only', (done) => { + const mock = new MockAdapter(Axios) + mock + .onGet('/content_types/content_type_uid/entries/UID/variants') + .reply((config) => { + expect(config.headers.branch).to.equal('staging') + return [200, { + entries: [varinatsEntryMock] + }] + }) + makeEntry({ + entry: { ...systemUidMock }, + stackHeaders: { api_key: 'k' } + }) + .variants(null, 'staging') + .query() + .find() + .then((collection) => { + checkEntry(collection.items[0].variants) + done() + }) + .catch(done) + }) }) function makeEntryVariants (data) { diff --git a/types/stack/contentType/entry.d.ts b/types/stack/contentType/entry.d.ts index 71b7c214..4a33a465 100644 --- a/types/stack/contentType/entry.d.ts +++ b/types/stack/contentType/entry.d.ts @@ -8,7 +8,7 @@ import { Variants, Variant } from "./variants"; export interface Entry extends Publishable, Unpublishable, SystemFields, SystemFunction { variants(): Variants - variants(uid: string): Variant + variants(variantUidOrUids: string | string[], branchName?: string): Variant setWorkflowStage(data: { workflow_stage: WorkflowStage, locale?:string}): Promise locales(): Promise references(param: object): Promise diff --git a/types/stack/contentType/variants.d.ts b/types/stack/contentType/variants.d.ts index b584785f..da8f973f 100644 --- a/types/stack/contentType/variants.d.ts +++ b/types/stack/contentType/variants.d.ts @@ -2,7 +2,21 @@ import { Response } from "../../contentstackCollection"; import { AnyProperty, SystemFields } from "../../utility/fields"; import { Queryable, SystemFunction } from "../../utility/operations"; +/** Options for {@link Variant.publish} and {@link Variant.unpublish} (entry publish/unpublish APIs with variant payload). */ +export interface VariantPublishUnpublishOptions { + publishDetails: AnyProperty + locale?: string | null + version?: number | null + scheduledAt?: string | null + /** Merged with stack headers on the request */ + headers?: Record + /** Query string parameters */ + params?: Record +} + export interface Variant extends SystemFields, SystemFunction { + publish(options: VariantPublishUnpublishOptions): Promise + unpublish(options: VariantPublishUnpublishOptions): Promise } export interface Variants extends Queryable { } diff --git a/types/utility/publish.d.ts b/types/utility/publish.d.ts index 4cc63427..68085f38 100644 --- a/types/utility/publish.d.ts +++ b/types/utility/publish.d.ts @@ -11,6 +11,10 @@ export interface PublishConfig { locale?: string version?: number scheduledAt?: string + /** Extra HTTP headers merged with stack headers (publish / unpublish). */ + headers?: Record + /** Query string parameters (publish / unpublish). */ + params?: Record } export interface Publishable {