From 1074d19f8ed9ea938464d374b176a8d7738cc974 Mon Sep 17 00:00:00 2001
From: sunil-lakshman <104969541+sunil-lakshman@users.noreply.github.com>
Date: Thu, 9 Apr 2026 12:52:21 +0530
Subject: [PATCH] Added header support in all modules
---
CHANGELOG.md | 7 +
lib/core/moduleHeaderSupport.js | 124 +++++++++
lib/query/index.js | 14 +-
lib/stack/asset/folders/index.js | 2 +
lib/stack/asset/index.js | 2 +
lib/stack/auditlog/index.js | 2 +
lib/stack/branch/compare.js | 2 +
lib/stack/branch/index.js | 2 +
lib/stack/branch/mergeQueue.js | 2 +
lib/stack/branchAlias/index.js | 2 +
lib/stack/bulkOperation/index.js | 2 +
lib/stack/contentType/entry/index.js | 2 +
lib/stack/contentType/entry/variants/index.js | 2 +
lib/stack/contentType/index.js | 2 +
lib/stack/deliveryToken/index.js | 2 +
lib/stack/deliveryToken/previewToken/index.js | 2 +
lib/stack/environment/index.js | 2 +
lib/stack/extension/index.js | 2 +
lib/stack/globalField/index.js | 2 +
lib/stack/index.js | 2 +
lib/stack/label/index.js | 2 +
lib/stack/locale/index.js | 2 +
lib/stack/managementToken/index.js | 2 +
lib/stack/release/index.js | 2 +
lib/stack/release/items/index.js | 2 +
lib/stack/roles/index.js | 2 +
lib/stack/taxonomy/index.js | 2 +
lib/stack/taxonomy/terms/index.js | 2 +
lib/stack/variantGroup/index.js | 2 +
lib/stack/variantGroup/variants/index.js | 2 +
lib/stack/variants/index.js | 2 +
lib/stack/webhook/index.js | 2 +
lib/stack/workflow/index.js | 2 +
lib/stack/workflow/publishRules/index.js | 2 +
package-lock.json | 4 +-
package.json | 2 +-
test/sanity-check/api/entry-test.js | 2 +-
test/sanity-check/api/globalfield-test.js | 40 ++-
.../api/moduleHeaderInjection-test.js | 256 +++++++++++++++++
test/sanity-check/api/taxonomy-test.js | 4 +-
test/sanity-check/sanity.js | 3 +
test/sanity-check/utility/testSetup.js | 3 +
test/unit/index.js | 1 +
test/unit/moduleHeaderSupport-test.js | 260 ++++++++++++++++++
44 files changed, 760 insertions(+), 22 deletions(-)
create mode 100644 lib/core/moduleHeaderSupport.js
create mode 100644 test/sanity-check/api/moduleHeaderInjection-test.js
create mode 100644 test/unit/moduleHeaderSupport-test.js
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 16192231..ef5e503b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
# Changelog
+## [v1.30.0](https://github.com/contentstack/contentstack-management-javascript/tree/v1.30.0) (2026-04-13)
+
+- Enh
+ - Per-module CMA headers: `addHeader`, `addHeaderDict`, and `removeHeader` on stack resources and query builders (stack shares one map; other modules copy-on-write). Backward compatible if unused.
+- Test
+ - Unit + sanity coverage for header APIs; minor sanity robustness (taxonomy 412, nested GF plan skip, entry update timeout).
+
## [v1.29.2](https://github.com/contentstack/contentstack-management-javascript/tree/v1.29.2) (2026-04-06)
- Fix
diff --git a/lib/core/moduleHeaderSupport.js b/lib/core/moduleHeaderSupport.js
new file mode 100644
index 00000000..65c47bd9
--- /dev/null
+++ b/lib/core/moduleHeaderSupport.js
@@ -0,0 +1,124 @@
+import cloneDeep from 'lodash/cloneDeep'
+
+const moduleHeadersOwn = Symbol.for('contentstack.management.moduleHeadersOwn')
+
+/**
+ * Attaches `addHeader`, `addHeaderDict`, and `removeHeader` to a stack resource instance.
+ * Per-module headers merge into request stack headers and override parent keys for that
+ * instance only (copy-on-write). Pass `ownsHeadersInline: true` on Stack so the canonical
+ * stack header map stays shared across child modules until a child calls addHeader.
+ *
+ * @param {Object} instance - Resource instance that uses `stackHeaders` for CMA headers.
+ * @param {Object} [options]
+ * @param {boolean} [options.ownsHeadersInline=false] - Mutate `stackHeaders` in place (stack root).
+ * @returns {Object} The same instance for chaining.
+ */
+export function bindModuleHeaders (instance, { ownsHeadersInline = false } = {}) {
+ instance.addHeader = function (key, value) {
+ if (key === undefined || key === null) {
+ return this
+ }
+ prepareStackHeaders(this, ownsHeadersInline)
+ this.stackHeaders[key] = value
+ return this
+ }
+
+ instance.addHeaderDict = function (headerDict) {
+ if (!headerDict || typeof headerDict !== 'object') {
+ return this
+ }
+ prepareStackHeaders(this, ownsHeadersInline)
+ Object.assign(this.stackHeaders, headerDict)
+ return this
+ }
+
+ instance.removeHeader = function (key) {
+ if (key === undefined || key === null) {
+ return this
+ }
+ if (ownsHeadersInline) {
+ if (this.stackHeaders && Object.prototype.hasOwnProperty.call(this.stackHeaders, key)) {
+ delete this.stackHeaders[key]
+ }
+ return this
+ }
+ if (!this[moduleHeadersOwn]) {
+ if (!this.stackHeaders || !Object.prototype.hasOwnProperty.call(this.stackHeaders, key)) {
+ return this
+ }
+ prepareStackHeaders(this, ownsHeadersInline)
+ delete this.stackHeaders[key]
+ return this
+ }
+ if (this.stackHeaders && Object.prototype.hasOwnProperty.call(this.stackHeaders, key)) {
+ delete this.stackHeaders[key]
+ }
+ return this
+ }
+
+ return instance
+}
+
+function prepareStackHeaders (instance, ownsHeadersInline) {
+ if (ownsHeadersInline) {
+ if (!instance.stackHeaders) {
+ instance.stackHeaders = {}
+ }
+ return
+ }
+ if (!instance[moduleHeadersOwn]) {
+ instance.stackHeaders = cloneDeep(instance.stackHeaders || {})
+ instance[moduleHeadersOwn] = true
+ } else if (!instance.stackHeaders) {
+ instance.stackHeaders = {}
+ }
+}
+
+/**
+ * Attaches `addHeader`, `addHeaderDict`, and `removeHeader` for a mutable header map (e.g. query builder).
+ *
+ * @param {Object} target - Object to receive methods.
+ * @param {function(): Object} getHeaderMap - Returns the header key/value object used for requests.
+ * @returns {Object} The same target for chaining.
+ */
+export function bindHeaderTarget (target, getHeaderMap) {
+ target.addHeader = function (key, value) {
+ if (key === undefined || key === null) {
+ return this
+ }
+ const headers = getHeaderMap()
+ if (!headers) {
+ return this
+ }
+ headers[key] = value
+ return this
+ }
+
+ target.addHeaderDict = function (headerDict) {
+ if (!headerDict || typeof headerDict !== 'object') {
+ return this
+ }
+ const headers = getHeaderMap()
+ if (!headers) {
+ return this
+ }
+ Object.assign(headers, headerDict)
+ return this
+ }
+
+ target.removeHeader = function (key) {
+ if (key === undefined || key === null) {
+ return this
+ }
+ const headers = getHeaderMap()
+ if (!headers) {
+ return this
+ }
+ if (Object.prototype.hasOwnProperty.call(headers, key)) {
+ delete headers[key]
+ }
+ return this
+ }
+
+ return target
+}
diff --git a/lib/query/index.js b/lib/query/index.js
index ce0d890f..5d84f499 100644
--- a/lib/query/index.js
+++ b/lib/query/index.js
@@ -1,12 +1,12 @@
import error from '../core/contentstackError'
import cloneDeep from 'lodash/cloneDeep'
import ContentstackCollection from '../contentstackCollection'
+import { bindHeaderTarget } from '../core/moduleHeaderSupport.js'
export default function Query (http, urlPath, param, stackHeaders = null, wrapperCollection) {
const headers = {}
- if (stackHeaders) {
- headers.headers = stackHeaders
- }
+ const headerMap = stackHeaders && typeof stackHeaders === 'object' ? stackHeaders : {}
+ headers.headers = headerMap
var contentTypeUid = null
if (param) {
if (param.content_type_uid) {
@@ -43,7 +43,7 @@ export default function Query (http, urlPath, param, stackHeaders = null, wrappe
if (contentTypeUid) {
response.data.content_type_uid = contentTypeUid
}
- return new ContentstackCollection(response, http, stackHeaders, wrapperCollection)
+ return new ContentstackCollection(response, http, headerMap, wrapperCollection)
} else {
throw error(response)
}
@@ -114,7 +114,7 @@ export default function Query (http, urlPath, param, stackHeaders = null, wrappe
if (contentTypeUid) {
response.data.content_type_uid = contentTypeUid
}
- return new ContentstackCollection(response, http, stackHeaders, wrapperCollection)
+ return new ContentstackCollection(response, http, headerMap, wrapperCollection)
} else {
throw error(response)
}
@@ -123,9 +123,11 @@ export default function Query (http, urlPath, param, stackHeaders = null, wrappe
}
}
- return {
+ const api = {
count: count,
find: find,
findOne: findOne
}
+ bindHeaderTarget(api, () => headers.headers)
+ return api
}
diff --git a/lib/stack/asset/folders/index.js b/lib/stack/asset/folders/index.js
index 048ebac9..e4839ad7 100644
--- a/lib/stack/asset/folders/index.js
+++ b/lib/stack/asset/folders/index.js
@@ -5,6 +5,7 @@ import {
fetch,
create
} from '../../../entity'
+import { bindModuleHeaders } from '../../../core/moduleHeaderSupport.js'
/**
* Folders refer to Asset Folders.
@@ -84,6 +85,7 @@ export function Folder (http, data = {}) {
*/
this.create = create({ http: http })
}
+ bindModuleHeaders(this)
}
export function FolderCollection (http, data) {
diff --git a/lib/stack/asset/index.js b/lib/stack/asset/index.js
index 5dc0ec7e..be52eaef 100644
--- a/lib/stack/asset/index.js
+++ b/lib/stack/asset/index.js
@@ -10,6 +10,7 @@ import {
unpublish } from '../../entity'
import { Folder } from './folders'
import error from '../../core/contentstackError'
+import { bindModuleHeaders } from '../../core/moduleHeaderSupport.js'
import { ERROR_MESSAGES } from '../../core/errorMessages'
import FormData from 'form-data'
import { createReadStream } from 'fs'
@@ -297,6 +298,7 @@ export function Asset (http, data = {}) {
throw error(err)
}
}
+ bindModuleHeaders(this)
return this
}
diff --git a/lib/stack/auditlog/index.js b/lib/stack/auditlog/index.js
index e2ffa04f..66d0bcac 100644
--- a/lib/stack/auditlog/index.js
+++ b/lib/stack/auditlog/index.js
@@ -1,6 +1,7 @@
import cloneDeep from 'lodash/cloneDeep'
import error from '../../core/contentstackError'
import { fetchAll } from '../../entity'
+import { bindModuleHeaders } from '../../core/moduleHeaderSupport.js'
/**
*
@@ -63,6 +64,7 @@ export function AuditLog (http, data = {}) {
*/
this.fetchAll = fetchAll(http, LogCollection)
}
+ bindModuleHeaders(this)
return this
}
diff --git a/lib/stack/branch/compare.js b/lib/stack/branch/compare.js
index 640a9f32..fe035bf7 100644
--- a/lib/stack/branch/compare.js
+++ b/lib/stack/branch/compare.js
@@ -1,4 +1,5 @@
import { get } from '../../entity'
+import { bindModuleHeaders } from '../../core/moduleHeaderSupport.js'
/**
*
@@ -74,5 +75,6 @@ export function Compare (http, data = {}) {
return get(http, url, params, data)
}
+ bindModuleHeaders(this)
return this
}
diff --git a/lib/stack/branch/index.js b/lib/stack/branch/index.js
index 75580821..19a1fcc5 100644
--- a/lib/stack/branch/index.js
+++ b/lib/stack/branch/index.js
@@ -3,6 +3,7 @@ import { create, query, fetch, deleteEntity } from '../../entity'
import { Compare } from './compare'
import { MergeQueue } from './mergeQueue'
import error from '../../core/contentstackError'
+import { bindModuleHeaders } from '../../core/moduleHeaderSupport.js'
/**
*
@@ -205,6 +206,7 @@ export function Branch (http, data = {}) {
return new MergeQueue(http, mergeData)
}
}
+ bindModuleHeaders(this)
return this
}
diff --git a/lib/stack/branch/mergeQueue.js b/lib/stack/branch/mergeQueue.js
index 31db03f6..2ac06dcb 100644
--- a/lib/stack/branch/mergeQueue.js
+++ b/lib/stack/branch/mergeQueue.js
@@ -1,4 +1,5 @@
import { get } from '../../entity'
+import { bindModuleHeaders } from '../../core/moduleHeaderSupport.js'
export function MergeQueue (http, data = {}) {
this.stackHeaders = data.stackHeaders
@@ -43,5 +44,6 @@ export function MergeQueue (http, data = {}) {
}
}
+ bindModuleHeaders(this)
return this
}
diff --git a/lib/stack/branchAlias/index.js b/lib/stack/branchAlias/index.js
index 298aee1b..6ef9c6f9 100644
--- a/lib/stack/branchAlias/index.js
+++ b/lib/stack/branchAlias/index.js
@@ -2,6 +2,7 @@ import cloneDeep from 'lodash/cloneDeep'
import error from '../../core/contentstackError'
import { deleteEntity, fetchAll, parseData } from '../../entity'
import { Branch, BranchCollection } from '../branch'
+import { bindModuleHeaders } from '../../core/moduleHeaderSupport.js'
/**
*
@@ -110,5 +111,6 @@ export function BranchAlias (http, data = {}) {
*/
this.fetchAll = fetchAll(http, BranchCollection)
}
+ bindModuleHeaders(this)
return this
}
diff --git a/lib/stack/bulkOperation/index.js b/lib/stack/bulkOperation/index.js
index 6c0f1438..dec6d0ed 100644
--- a/lib/stack/bulkOperation/index.js
+++ b/lib/stack/bulkOperation/index.js
@@ -1,5 +1,6 @@
import cloneDeep from 'lodash/cloneDeep'
import { publishUnpublish } from '../../entity'
+import { bindModuleHeaders } from '../../core/moduleHeaderSupport.js'
/**
* Bulk operations such as Publish, Unpublish, and Delete on multiple entries or assets.
@@ -454,4 +455,5 @@ export function BulkOperation (http, data = {}) {
}
return publishUnpublish(http, '/bulk/workflow', updateBody, headers)
}
+ bindModuleHeaders(this)
}
diff --git a/lib/stack/contentType/entry/index.js b/lib/stack/contentType/entry/index.js
index 922bf42e..0f82561f 100644
--- a/lib/stack/contentType/entry/index.js
+++ b/lib/stack/contentType/entry/index.js
@@ -12,6 +12,7 @@ import { create,
import FormData from 'form-data'
import { createReadStream } from 'fs'
import error from '../../../core/contentstackError'
+import { bindModuleHeaders } from '../../../core/moduleHeaderSupport.js'
import { Variants } from './variants/index'
/**
@@ -486,6 +487,7 @@ export function Entry (http, data) {
throw error(err)
}
}
+ bindModuleHeaders(this)
return this
}
diff --git a/lib/stack/contentType/entry/variants/index.js b/lib/stack/contentType/entry/variants/index.js
index 1f7efed4..27f3f56f 100644
--- a/lib/stack/contentType/entry/variants/index.js
+++ b/lib/stack/contentType/entry/variants/index.js
@@ -6,6 +6,7 @@ import {
}
from '../../../../entity'
import error from '../../../../core/contentstackError'
+import { bindModuleHeaders } from '../../../../core/moduleHeaderSupport.js'
/**
* Variants allow you to create variant versions of entries within a content type. Read more about Variants.
* @namespace Variants
@@ -139,6 +140,7 @@ export function Variants (http, data) {
*/
this.query = query({ http: http, wrapperCollection: VariantsCollection })
}
+ bindModuleHeaders(this)
}
export function VariantsCollection (http, data) {
const obj = cloneDeep(data.entries) || []
diff --git a/lib/stack/contentType/index.js b/lib/stack/contentType/index.js
index ffd50f1f..3d0b84c4 100644
--- a/lib/stack/contentType/index.js
+++ b/lib/stack/contentType/index.js
@@ -11,6 +11,7 @@ import {
import { Entry } from './entry/index'
import error from '../../core/contentstackError'
import { ERROR_MESSAGES } from '../../core/errorMessages'
+import { bindModuleHeaders } from '../../core/moduleHeaderSupport.js'
import FormData from 'form-data'
import { createReadStream } from 'fs'
@@ -290,6 +291,7 @@ export function ContentType (http, data = {}) {
}
}
}
+ bindModuleHeaders(this)
return this
}
diff --git a/lib/stack/deliveryToken/index.js b/lib/stack/deliveryToken/index.js
index 50ba9541..69ce4bbc 100644
--- a/lib/stack/deliveryToken/index.js
+++ b/lib/stack/deliveryToken/index.js
@@ -1,6 +1,7 @@
import cloneDeep from 'lodash/cloneDeep'
import { create, update, deleteEntity, fetch, query } from '../../entity'
import { PreviewToken } from './previewToken'
+import { bindModuleHeaders } from '../../core/moduleHeaderSupport.js'
/**
* Delivery tokens provide read-only access to the associated environments. Read more about DeliveryToken.
@@ -117,6 +118,7 @@ export function DeliveryToken (http, data = {}) {
*/
this.query = query({ http: http, wrapperCollection: DeliveryTokenCollection })
}
+ bindModuleHeaders(this)
}
export function DeliveryTokenCollection (http, data) {
diff --git a/lib/stack/deliveryToken/previewToken/index.js b/lib/stack/deliveryToken/previewToken/index.js
index f438a09e..6734511f 100644
--- a/lib/stack/deliveryToken/previewToken/index.js
+++ b/lib/stack/deliveryToken/previewToken/index.js
@@ -1,5 +1,6 @@
import cloneDeep from 'lodash/cloneDeep'
import { create, deleteEntity } from '../../../entity'
+import { bindModuleHeaders } from '../../../core/moduleHeaderSupport.js'
/**
* Preview tokens provide read-only access to the associated environments. Read more about PreviewToken.
@@ -39,6 +40,7 @@ export function PreviewToken (http, data = {}) {
*/
this.create = create({ http: http })
}
+ bindModuleHeaders(this)
}
export function PreviewTokenCollection (http, data) {
diff --git a/lib/stack/environment/index.js b/lib/stack/environment/index.js
index 8c84f7d6..c3280915 100644
--- a/lib/stack/environment/index.js
+++ b/lib/stack/environment/index.js
@@ -1,5 +1,6 @@
import cloneDeep from 'lodash/cloneDeep'
import { create, update, deleteEntity, fetch, query } from '../../entity'
+import { bindModuleHeaders } from '../../core/moduleHeaderSupport.js'
/**
* A publishing environment corresponds to one or more deployment servers or a content delivery destination where the entries need to be published. Read more about Environment.
@@ -105,6 +106,7 @@ export function Environment (http, data = {}) {
*/
this.query = query({ http: http, wrapperCollection: EnvironmentCollection })
}
+ bindModuleHeaders(this)
}
export function EnvironmentCollection (http, data) {
diff --git a/lib/stack/extension/index.js b/lib/stack/extension/index.js
index 082cc90c..47438b6b 100644
--- a/lib/stack/extension/index.js
+++ b/lib/stack/extension/index.js
@@ -1,6 +1,7 @@
import cloneDeep from 'lodash/cloneDeep'
import { create, update, deleteEntity, fetch, query, upload, parseData } from '../../entity'
import error from '../../core/contentstackError'
+import { bindModuleHeaders } from '../../core/moduleHeaderSupport.js'
import FormData from 'form-data'
import { createReadStream } from 'fs'
@@ -147,6 +148,7 @@ export function Extension (http, data = {}) {
*/
this.query = query({ http: http, wrapperCollection: ExtensionCollection })
}
+ bindModuleHeaders(this)
}
export function ExtensionCollection (http, data) {
diff --git a/lib/stack/globalField/index.js b/lib/stack/globalField/index.js
index 8370a3e7..6644b526 100644
--- a/lib/stack/globalField/index.js
+++ b/lib/stack/globalField/index.js
@@ -3,6 +3,7 @@ import { create, update, deleteEntity, fetch, query, upload, parseData } from '.
import error from '../../core/contentstackError'
import FormData from 'form-data'
import { createReadStream } from 'fs'
+import { bindModuleHeaders } from '../../core/moduleHeaderSupport.js'
/**
* GlobalField defines the structure or schema of a page or a section of your web or mobile property. To create global Fields for your application, you are required to first create a global field. Read more about Global Fields.
@@ -153,6 +154,7 @@ export function GlobalField (http, data = {}) {
}
}
}
+ bindModuleHeaders(this)
return this
}
diff --git a/lib/stack/index.js b/lib/stack/index.js
index f614e680..ad78b182 100644
--- a/lib/stack/index.js
+++ b/lib/stack/index.js
@@ -1,5 +1,6 @@
import cloneDeep from 'lodash/cloneDeep'
import error from '../core/contentstackError'
+import { bindModuleHeaders } from '../core/moduleHeaderSupport.js'
import { UserCollection } from '../user/index'
import { Role } from './roles/index'
import { create, query, update, fetch, deleteEntity } from '../entity'
@@ -892,6 +893,7 @@ export function Stack (http, data) {
*/
this.query = query({ http: http, wrapperCollection: StackCollection })
}
+ bindModuleHeaders(this, { ownsHeadersInline: true })
return this
}
diff --git a/lib/stack/label/index.js b/lib/stack/label/index.js
index 0cee2cf3..414aada9 100644
--- a/lib/stack/label/index.js
+++ b/lib/stack/label/index.js
@@ -6,6 +6,7 @@ import {
query,
create
} from '../../entity'
+import { bindModuleHeaders } from '../../core/moduleHeaderSupport.js'
/**
* Labels allow you to group a collection of content within a stack. Using labels you can group content types that need to work together. Read more about Labels.
* @namespace Label
@@ -100,6 +101,7 @@ export function Label (http, data) {
*/
this.query = query({ http: http, wrapperCollection: LabelCollection })
}
+ bindModuleHeaders(this)
}
export function LabelCollection (http, data) {
diff --git a/lib/stack/locale/index.js b/lib/stack/locale/index.js
index ec00a9f2..4a3712b0 100644
--- a/lib/stack/locale/index.js
+++ b/lib/stack/locale/index.js
@@ -1,5 +1,6 @@
import cloneDeep from 'lodash/cloneDeep'
import { create, update, deleteEntity, fetch, query } from '../../entity'
+import { bindModuleHeaders } from '../../core/moduleHeaderSupport.js'
/**
* Contentstack has a sophisticated multilingual capability. It allows you to create and publish entries in any language. This feature allows you to set up multilingual websites and cater to a wide variety of audience by serving content in their local language(s). Read more about Locales.
@@ -96,6 +97,7 @@ export function Locale (http, data = {}) {
*/
this.query = query({ http: http, wrapperCollection: LocaleCollection })
}
+ bindModuleHeaders(this)
return this
}
diff --git a/lib/stack/managementToken/index.js b/lib/stack/managementToken/index.js
index 1d5a8c78..9690b204 100644
--- a/lib/stack/managementToken/index.js
+++ b/lib/stack/managementToken/index.js
@@ -1,5 +1,6 @@
import cloneDeep from 'lodash/cloneDeep'
import { create, update, deleteEntity, fetch, query } from '../../entity'
+import { bindModuleHeaders } from '../../core/moduleHeaderSupport.js'
/**
* Management tokens provide read-write access to the associated environments. Read more about ManagementToken.
@@ -100,6 +101,7 @@ export function ManagementToken (http, data = {}) {
*/
this.query = query({ http: http, wrapperCollection: ManagementTokenCollection })
}
+ bindModuleHeaders(this)
}
export function ManagementTokenCollection (http, data) {
diff --git a/lib/stack/release/index.js b/lib/stack/release/index.js
index 63e5bfd1..572b9fea 100644
--- a/lib/stack/release/index.js
+++ b/lib/stack/release/index.js
@@ -6,6 +6,7 @@ import {
query
} from '../../entity'
import error from '../../core/contentstackError'
+import { bindModuleHeaders } from '../../core/moduleHeaderSupport.js'
import { ReleaseItem, ReleaseItemCollection } from './items'
/**
* @description You can pin a set of entries and assets (along with the deploy action, i.e., publish/unpublish) to a ‘release’, and then deploy this release to an environment.
@@ -257,6 +258,7 @@ export function Release (http, data = {}) {
*/
this.query = query({ http: http, wrapperCollection: ReleaseCollection })
}
+ bindModuleHeaders(this)
return this
}
diff --git a/lib/stack/release/items/index.js b/lib/stack/release/items/index.js
index 1bbd95db..61efe050 100644
--- a/lib/stack/release/items/index.js
+++ b/lib/stack/release/items/index.js
@@ -2,6 +2,7 @@ import cloneDeep from 'lodash/cloneDeep'
import error from '../../../core/contentstackError'
import ContentstackCollection from '../../../contentstackCollection'
import { Release } from '..'
+import { bindModuleHeaders } from '../../../core/moduleHeaderSupport.js'
/**
* A ReleaseItem is a set of entries and assets that needs to be deployed (published or unpublished) all at once to a particular environment.
* @namespace ReleaseItem
@@ -245,6 +246,7 @@ export function ReleaseItem (http, data = {}) {
}
}
}
+ bindModuleHeaders(this)
return this
}
diff --git a/lib/stack/roles/index.js b/lib/stack/roles/index.js
index 6d5627de..bc425598 100644
--- a/lib/stack/roles/index.js
+++ b/lib/stack/roles/index.js
@@ -1,5 +1,6 @@
import cloneDeep from 'lodash/cloneDeep'
import { create, update, deleteEntity, fetch, query, fetchAll } from '../../entity'
+import { bindModuleHeaders } from '../../core/moduleHeaderSupport.js'
/**
* A role is a collection of permissions that will be applicable to all the users who are assigned this role. Read more about Roles.
* @namespace Role
@@ -150,6 +151,7 @@ export function Role (http, data) {
*/
this.query = query({ http: http, wrapperCollection: RoleCollection })
}
+ bindModuleHeaders(this)
return this
}
diff --git a/lib/stack/taxonomy/index.js b/lib/stack/taxonomy/index.js
index 841b6df6..78aa0323 100644
--- a/lib/stack/taxonomy/index.js
+++ b/lib/stack/taxonomy/index.js
@@ -13,6 +13,7 @@ import { Terms, TermsCollection } from './terms'
import FormData from 'form-data'
import { createReadStream } from 'fs'
import error from '../../core/contentstackError'
+import { bindModuleHeaders } from '../../core/moduleHeaderSupport.js'
export function Taxonomy (http, data = {}) {
this.stackHeaders = data.stackHeaders
@@ -320,6 +321,7 @@ export function Taxonomy (http, data = {}) {
}
}
}
+ bindModuleHeaders(this)
}
export function TaxonomyCollection (http, data) {
const obj = cloneDeep(data.taxonomies) || []
diff --git a/lib/stack/taxonomy/terms/index.js b/lib/stack/taxonomy/terms/index.js
index f90455dc..3461c0a3 100644
--- a/lib/stack/taxonomy/terms/index.js
+++ b/lib/stack/taxonomy/terms/index.js
@@ -9,6 +9,7 @@ import {
parseData
} from '../../../entity'
import error from '../../../core/contentstackError'
+import { bindModuleHeaders } from '../../../core/moduleHeaderSupport.js'
/**
* Terms are individual items within a taxonomy that allow you to organize and categorize content.
@@ -265,6 +266,7 @@ export function Terms (http, data) {
throw err
}
}
+ bindModuleHeaders(this)
}
export function TermsCollection (http, data) {
const obj = cloneDeep(data.terms) || []
diff --git a/lib/stack/variantGroup/index.js b/lib/stack/variantGroup/index.js
index d6736a3c..33de4a4a 100644
--- a/lib/stack/variantGroup/index.js
+++ b/lib/stack/variantGroup/index.js
@@ -2,6 +2,7 @@ import cloneDeep from 'lodash/cloneDeep'
import { deleteEntity, query } from '../../entity'
import { Variants } from './variants/index'
import error from '../../core/contentstackError'
+import { bindModuleHeaders } from '../../core/moduleHeaderSupport.js'
/**
* VariantGroups allow you to group a collection of variants within a stack. Using variant groups you can organize variants that need to work together. Read more about VariantGroups.
@@ -135,6 +136,7 @@ export function VariantGroup (http, data = {}) {
*/
this.query = query({ http: http, wrapperCollection: VariantGroupCollection })
}
+ bindModuleHeaders(this)
return this
}
diff --git a/lib/stack/variantGroup/variants/index.js b/lib/stack/variantGroup/variants/index.js
index 9da98fdb..c4759e6d 100644
--- a/lib/stack/variantGroup/variants/index.js
+++ b/lib/stack/variantGroup/variants/index.js
@@ -1,6 +1,7 @@
import cloneDeep from 'lodash/cloneDeep'
import { query, deleteEntity } from '../../../entity'
import error from '../../../core/contentstackError'
+import { bindModuleHeaders } from '../../../core/moduleHeaderSupport.js'
/**
* Variants within a variant group allow you to manage individual variants that belong to a specific variant group. Read more about Variants.
@@ -153,6 +154,7 @@ export function Variants (http, data = {}) {
*/
this.query = query({ http: http, wrapperCollection: VariantsCollection })
}
+ bindModuleHeaders(this)
return this
}
diff --git a/lib/stack/variants/index.js b/lib/stack/variants/index.js
index f87596fd..56a336d2 100644
--- a/lib/stack/variants/index.js
+++ b/lib/stack/variants/index.js
@@ -4,6 +4,7 @@ import {
query
} from '../../entity'
import error from '../../core/contentstackError'
+import { bindModuleHeaders } from '../../core/moduleHeaderSupport.js'
/**
* Variants allow you to group a collection of content within a stack. Using variants you can group content types that need to work together. Read more about Variants.
* @namespace Variants
@@ -155,6 +156,7 @@ export function Variants (http, data) {
}
}
}
+ bindModuleHeaders(this)
}
export function VariantsCollection (http, data) {
diff --git a/lib/stack/webhook/index.js b/lib/stack/webhook/index.js
index 3d2834f6..66a58266 100644
--- a/lib/stack/webhook/index.js
+++ b/lib/stack/webhook/index.js
@@ -11,6 +11,7 @@ import {
import error from '../../core/contentstackError'
import FormData from 'form-data'
import { createReadStream } from 'fs'
+import { bindModuleHeaders } from '../../core/moduleHeaderSupport.js'
/**
* A webhook is a mechanism that sends real-time information to any third-party app or service to keep your application in sync with your Contentstack account. Webhooks allow you to specify a URL to which you would like Contentstack to post data when an event happens. Read more about Webhooks.
@@ -231,6 +232,7 @@ export function Webhook (http, data = {}) {
throw error(err)
}
}
+ bindModuleHeaders(this)
return this
}
diff --git a/lib/stack/workflow/index.js b/lib/stack/workflow/index.js
index 71d9f66b..c0b4bfe1 100644
--- a/lib/stack/workflow/index.js
+++ b/lib/stack/workflow/index.js
@@ -9,6 +9,7 @@ import {
import error from '../../core/contentstackError'
import ContentstackCollection from '../../contentstackCollection'
import { PublishRules, PublishRulesCollection } from './publishRules'
+import { bindModuleHeaders } from '../../core/moduleHeaderSupport.js'
/**
* Workflow is a tool that allows you to streamline the process of content creation and publishing, and lets you manage the content lifecycle of your project smoothly. Read more about Workflows and Publish Rules.
@@ -297,6 +298,7 @@ export function Workflow (http, data = {}) {
return new PublishRules(http, publishInfo)
}
}
+ bindModuleHeaders(this)
}
export function WorkflowCollection (http, data) {
diff --git a/lib/stack/workflow/publishRules/index.js b/lib/stack/workflow/publishRules/index.js
index 64a926ef..cc2b06b8 100644
--- a/lib/stack/workflow/publishRules/index.js
+++ b/lib/stack/workflow/publishRules/index.js
@@ -6,6 +6,7 @@ import {
fetch,
fetchAll
} from '../../../entity'
+import { bindModuleHeaders } from '../../../core/moduleHeaderSupport.js'
/**
* PublishRules is a tool that allows you to streamline the process of content creation and publishing, and lets you manage the content lifecycle of your project smoothly. Read more about PublishRules and Publish Rules.
@@ -114,6 +115,7 @@ export function PublishRules (http, data = {}) {
*/
this.fetchAll = fetchAll(http, PublishRulesCollection)
}
+ bindModuleHeaders(this)
}
export function PublishRulesCollection (http, data) {
diff --git a/package-lock.json b/package-lock.json
index e88310a3..17897fda 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@contentstack/management",
- "version": "1.29.2",
+ "version": "1.30.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@contentstack/management",
- "version": "1.29.2",
+ "version": "1.30.0",
"license": "MIT",
"dependencies": {
"@contentstack/utils": "^1.8.0",
diff --git a/package.json b/package.json
index 1d0227c8..075008aa 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@contentstack/management",
- "version": "1.29.2",
+ "version": "1.30.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/entry-test.js b/test/sanity-check/api/entry-test.js
index 5db5eec9..04e9b03d 100644
--- a/test/sanity-check/api/entry-test.js
+++ b/test/sanity-check/api/entry-test.js
@@ -214,7 +214,7 @@ describe('Entry API Tests', () => {
})
it('should update entry with partial data', async function () {
- this.timeout(15000)
+ this.timeout(45000)
if (!entryUid) this.skip()
const entry = await stack.contentType(mediumCtUid).entry(entryUid).fetch()
diff --git a/test/sanity-check/api/globalfield-test.js b/test/sanity-check/api/globalfield-test.js
index 349624e3..73b5a33f 100644
--- a/test/sanity-check/api/globalfield-test.js
+++ b/test/sanity-check/api/globalfield-test.js
@@ -480,6 +480,14 @@ describe('Global Field API Tests', () => {
describe('Nested Global Fields (api_version 3.2)', () => {
const baseGfUid = `base_gf_${Date.now()}`
const nestedGfUid = `ngf_parent_${Date.now()}`
+ /** Set when API returns plan / feature not enabled (remaining examples in this block skip). */
+ let skipNestedGlobalFieldSuite = false
+
+ function isNestedGlobalFieldPlanError (e) {
+ if (!e || e.status !== 422) return false
+ const msg = `${e.errorMessage || ''} ${JSON.stringify(e.errors || {})}`
+ return /not part of your plan|Nested Global Fields/i.test(msg)
+ }
after(async function () {
this.timeout(60000)
@@ -557,23 +565,33 @@ describe('Global Field API Tests', () => {
}
}
- const response = await stack.globalField({ api_version: '3.2' }).create(gfData)
+ try {
+ const response = await stack.globalField({ api_version: '3.2' }).create(gfData)
- expect(response).to.be.an('object')
- const gf = response.global_field || response
- expect(gf.uid).to.equal(nestedGfUid)
+ expect(response).to.be.an('object')
+ const gf = response.global_field || response
+ expect(gf.uid).to.equal(nestedGfUid)
- // Validate nested field structure
- const nestedField = gf.schema.find(f => f.data_type === 'global_field')
- expect(nestedField).to.exist
- expect(nestedField.reference_to).to.equal(baseGfUid)
+ // Validate nested field structure
+ const nestedField = gf.schema.find(f => f.data_type === 'global_field')
+ expect(nestedField).to.exist
+ expect(nestedField.reference_to).to.equal(baseGfUid)
- testData.globalFields.nestedParent = gf
- await wait(2000)
+ testData.globalFields.nestedParent = gf
+ await wait(2000)
+ } catch (e) {
+ if (isNestedGlobalFieldPlanError(e)) {
+ console.log(' Nested global fields not enabled on stack plan — skipping nested GF examples')
+ skipNestedGlobalFieldSuite = true
+ this.skip()
+ }
+ throw e
+ }
})
it('should fetch nested global field with api_version 3.2', async function () {
this.timeout(15000)
+ if (skipNestedGlobalFieldSuite) this.skip()
const response = await stack.globalField(nestedGfUid, { api_version: '3.2' }).fetch()
@@ -598,6 +616,7 @@ describe('Global Field API Tests', () => {
it('should update nested global field', async function () {
this.timeout(30000)
+ if (skipNestedGlobalFieldSuite) this.skip()
const gf = await stack.globalField(nestedGfUid, { api_version: '3.2' }).fetch()
const newTitle = `Updated Nested ${Date.now()}`
@@ -610,6 +629,7 @@ describe('Global Field API Tests', () => {
it('should validate nested global field schema structure', async function () {
this.timeout(15000)
+ if (skipNestedGlobalFieldSuite) this.skip()
const gf = await stack.globalField(nestedGfUid, { api_version: '3.2' }).fetch()
diff --git a/test/sanity-check/api/moduleHeaderInjection-test.js b/test/sanity-check/api/moduleHeaderInjection-test.js
new file mode 100644
index 00000000..dfe33f68
--- /dev/null
+++ b/test/sanity-check/api/moduleHeaderInjection-test.js
@@ -0,0 +1,256 @@
+/**
+ * Module header injection — live API checks
+ *
+ * Only the `branch` stack header is added/removed (no custom x-headers). Branch UID comes
+ * from Branch API Tests (`testData.branches.development`), with fallback to `main`.
+ *
+ * Uses the same credentials as the rest of the sanity suite (.env + setup() API_KEY).
+ * Assertions use the SDK request-capture plugin (testSetup.js).
+ *
+ * The shared instrumented client can end up with `authorization` on axios defaults after
+ * other suites; the queue then drops `authtoken` and CMA returns 401. This file resets
+ * auth to `authtoken` before running.
+ *
+ * Run only this suite:
+ * npm run build && npm run test:sanity-nocov -- --grep "Module header injection"
+ */
+
+import { expect } from 'chai'
+import { describe, it, before } from 'mocha'
+import { contentstackClient } from '../utility/ContentstackClient.js'
+import * as testSetup from '../utility/testSetup.js'
+import { trackedExpect, testData } from '../utility/testHelpers.js'
+
+const { clearCapturedRequests, getCapturedRequests } = testSetup
+
+/**
+ * Branch UID created in `branch-test.js` (development branch from main).
+ */
+function branchUidForHeaders () {
+ const uid = testData.branches?.development?.uid
+ return uid || 'main'
+}
+
+/**
+ * Axios may normalize header names; resolve case-insensitively.
+ */
+function getHeaderValue (headers, name) {
+ if (!headers || typeof headers !== 'object') return undefined
+ const target = String(name).toLowerCase()
+ for (const key of Object.keys(headers)) {
+ if (key.toLowerCase() === target) {
+ return headers[key]
+ }
+ }
+ return undefined
+}
+
+/**
+ * Completed captures include HTTP status (plugin records after response).
+ */
+function pickLastCompletedRequest (pathSubstring) {
+ const list = getCapturedRequests()
+ const matches = list.filter(
+ (c) => c.status != null && c.url && c.url.includes(pathSubstring)
+ )
+ return matches[matches.length - 1]
+}
+
+/**
+ * Prefer authtoken on the shared client so CMA accepts requests after long runs.
+ */
+function ensureSharedClientUsesAuthtoken () {
+ const sdkClient = contentstackClient()
+ const ax = sdkClient?.axiosInstance
+ if (!ax?.defaults?.headers) return
+
+ const authtoken =
+ testSetup.testContext?.authtoken || process.env.AUTHTOKEN
+ if (!authtoken) return
+
+ const stripAuthorization = (obj) => {
+ if (obj && typeof obj === 'object') {
+ delete obj.authorization
+ }
+ }
+
+ stripAuthorization(ax.defaults.headers)
+ if (ax.defaults.headers.common) {
+ stripAuthorization(ax.defaults.headers.common)
+ ax.defaults.headers.common.authtoken = authtoken
+ }
+ delete ax.defaults.authorization
+
+ const p = ax.httpClientParams
+ if (p) {
+ stripAuthorization(p)
+ if (p.headers) stripAuthorization(p.headers)
+ p.authtoken = authtoken
+ if (!p.headers) p.headers = {}
+ p.headers.authtoken = authtoken
+ delete p.authorization
+ }
+}
+
+describe('Module header injection API Tests', () => {
+ let client
+ let stack
+ let branchUid
+
+ before(function () {
+ ensureSharedClientUsesAuthtoken()
+ client = contentstackClient()
+ stack = client.stack({ api_key: process.env.API_KEY })
+ branchUid = branchUidForHeaders()
+ })
+
+ it('should send stack-level addHeader(branch) on child module requests', async function () {
+ this.timeout(30000)
+ ensureSharedClientUsesAuthtoken()
+ clearCapturedRequests()
+
+ const stackScoped = client.stack({ api_key: process.env.API_KEY })
+ stackScoped.addHeader('branch', branchUid)
+
+ const response = await stackScoped.locale().query().find()
+ trackedExpect(response, 'Locales response').toBeAn('object')
+
+ const req = pickLastCompletedRequest('/locales')
+ expect(req, 'expected a captured GET /locales after locale query').to.exist
+ expect(String(getHeaderValue(req.headers, 'branch'))).to.equal(String(branchUid))
+ })
+
+ it('should send module addHeader(branch) on locale query requests', async function () {
+ this.timeout(30000)
+ ensureSharedClientUsesAuthtoken()
+ clearCapturedRequests()
+
+ const response = await stack
+ .locale()
+ .addHeader('branch', branchUid)
+ .query()
+ .find()
+
+ trackedExpect(response, 'Locales response').toBeAn('object')
+
+ const req = pickLastCompletedRequest('/locales')
+ expect(req, 'expected a captured GET /locales').to.exist
+ expect(String(getHeaderValue(req.headers, 'branch'))).to.equal(String(branchUid))
+ })
+
+ it('should send module addHeaderDict with only branch on audit log list', async function () {
+ this.timeout(30000)
+ ensureSharedClientUsesAuthtoken()
+ clearCapturedRequests()
+
+ try {
+ await stack.auditLog().addHeaderDict({ branch: branchUid }).fetchAll({ limit: 1 })
+ } catch (e) {
+ console.log(
+ 'Audit log fetchAll skipped (permissions):',
+ e?.message || e
+ )
+ return
+ }
+
+ const req = pickLastCompletedRequest('/audit-logs')
+ if (!req) {
+ console.log('No completed /audit-logs capture; skipping header assertions')
+ return
+ }
+ expect(String(getHeaderValue(req.headers, 'branch'))).to.equal(String(branchUid))
+ })
+
+ it('should chain addHeader(branch) on query().find() for locales', async function () {
+ this.timeout(30000)
+ ensureSharedClientUsesAuthtoken()
+ clearCapturedRequests()
+
+ const response = await stack
+ .locale()
+ .query()
+ .addHeader('branch', branchUid)
+ .find()
+
+ trackedExpect(response, 'Locales response').toBeAn('object')
+
+ const req = pickLastCompletedRequest('/locales')
+ expect(req, 'expected a captured GET /locales').to.exist
+ expect(String(getHeaderValue(req.headers, 'branch'))).to.equal(String(branchUid))
+ })
+
+ it('should omit stack-level branch after removeHeader on child requests', async function () {
+ this.timeout(30000)
+ ensureSharedClientUsesAuthtoken()
+ clearCapturedRequests()
+
+ const stackScoped = client.stack({ api_key: process.env.API_KEY })
+ stackScoped.addHeader('branch', branchUid).removeHeader('branch')
+
+ const response = await stackScoped.locale().query().find()
+ trackedExpect(response, 'Locales response').toBeAn('object')
+
+ const req = pickLastCompletedRequest('/locales')
+ expect(req, 'expected a captured GET /locales').to.exist
+ expect(getHeaderValue(req.headers, 'branch')).to.equal(undefined)
+ })
+
+ it('should omit module branch after removeHeader before locale query', async function () {
+ this.timeout(30000)
+ ensureSharedClientUsesAuthtoken()
+ clearCapturedRequests()
+
+ const response = await stack
+ .locale()
+ .addHeader('branch', branchUid)
+ .removeHeader('branch')
+ .query()
+ .find()
+
+ trackedExpect(response, 'Locales response').toBeAn('object')
+
+ const req = pickLastCompletedRequest('/locales')
+ expect(req, 'expected a captured GET /locales').to.exist
+ expect(getHeaderValue(req.headers, 'branch')).to.equal(undefined)
+ })
+
+ it('should omit branch after removeHeader on query().find()', async function () {
+ this.timeout(30000)
+ ensureSharedClientUsesAuthtoken()
+ clearCapturedRequests()
+
+ const response = await stack
+ .locale()
+ .query()
+ .addHeader('branch', branchUid)
+ .removeHeader('branch')
+ .find()
+
+ trackedExpect(response, 'Locales response').toBeAn('object')
+
+ const req = pickLastCompletedRequest('/locales')
+ expect(req, 'expected a captured GET /locales').to.exist
+ expect(getHeaderValue(req.headers, 'branch')).to.equal(undefined)
+ })
+
+ it('should isolate branch header: asset query vs webhook list', async function () {
+ this.timeout(45000)
+ ensureSharedClientUsesAuthtoken()
+ clearCapturedRequests()
+
+ await stack.asset().addHeader('branch', branchUid).query().find()
+
+ const assetReq = pickLastCompletedRequest('/assets')
+ expect(assetReq, 'expected a captured GET /assets').to.exist
+ expect(String(getHeaderValue(assetReq.headers, 'branch'))).to.equal(
+ String(branchUid)
+ )
+
+ clearCapturedRequests()
+ await stack.webhook().fetchAll({ limit: 1 })
+
+ const webhookReq = pickLastCompletedRequest('/webhooks')
+ expect(webhookReq, 'expected a captured GET /webhooks').to.exist
+ expect(getHeaderValue(webhookReq.headers, 'branch')).to.equal(undefined)
+ })
+})
diff --git a/test/sanity-check/api/taxonomy-test.js b/test/sanity-check/api/taxonomy-test.js
index 4ada5142..87e0ec52 100644
--- a/test/sanity-check/api/taxonomy-test.js
+++ b/test/sanity-check/api/taxonomy-test.js
@@ -272,7 +272,7 @@ describe('Taxonomy API Tests', () => {
expect(response).to.be.an('object')
} catch (e) {
// Feature may not be available on all environments; accept 4xx errors gracefully
- expect(e.status).to.be.oneOf([400, 403, 404, 422])
+ expect(e.status).to.be.oneOf([400, 403, 404, 412, 422])
}
})
@@ -298,7 +298,7 @@ describe('Taxonomy API Tests', () => {
expect(response).to.be.an('object')
} catch (e) {
// Feature may not be available on all environments; accept 4xx errors gracefully
- expect(e.status).to.be.oneOf([400, 403, 404, 422])
+ expect(e.status).to.be.oneOf([400, 403, 404, 412, 422])
}
})
diff --git a/test/sanity-check/sanity.js b/test/sanity-check/sanity.js
index a25cfcd5..91cf9b4a 100644
--- a/test/sanity-check/sanity.js
+++ b/test/sanity-check/sanity.js
@@ -136,6 +136,9 @@ import './api/bulkOperation-test.js'
// Phase 22: Audit Log (runs after most operations for logs)
import './api/auditlog-test.js'
+// Phase 22b: Module header injection (addHeader / addHeaderDict) — uses request capture
+import './api/moduleHeaderInjection-test.js'
+
// Phase 23: OAuth Authentication
import './api/oauth-test.js'
dotenv.config()
diff --git a/test/sanity-check/utility/testSetup.js b/test/sanity-check/utility/testSetup.js
index 5c76393e..074c9e2f 100644
--- a/test/sanity-check/utility/testSetup.js
+++ b/test/sanity-check/utility/testSetup.js
@@ -27,6 +27,9 @@
* - API_KEY: Generated when test stack is created
* - MANAGEMENT_TOKEN: Generated for the test stack
* - PERSONALIZE_PROJECT_UID: Generated when personalize project is created
+ *
+ * Module header injection sanity tests (api/moduleHeaderInjection-test.js) use the same
+ * env and assert on captured request headers from the SDK plugin — no extra variables.
*/
// Import from dist (built package) - tests the exact artifact customers use
diff --git a/test/unit/index.js b/test/unit/index.js
index 90c965fc..5eba9892 100644
--- a/test/unit/index.js
+++ b/test/unit/index.js
@@ -9,6 +9,7 @@ require('./user-test')
require('./organization-test')
require('./role-test')
require('./stack-test')
+require('./moduleHeaderSupport-test')
require('./asset-folder-test')
require('./extension-test')
require('./branch-test')
diff --git a/test/unit/moduleHeaderSupport-test.js b/test/unit/moduleHeaderSupport-test.js
new file mode 100644
index 00000000..371622b1
--- /dev/null
+++ b/test/unit/moduleHeaderSupport-test.js
@@ -0,0 +1,260 @@
+import Axios from 'axios'
+import { expect } from 'chai'
+import { describe, it } from 'mocha'
+import MockAdapter from 'axios-mock-adapter'
+import { Stack } from '../../lib/stack/index'
+import Query from '../../lib/query'
+import { EntryCollection } from '../../lib/stack/contentType/entry'
+import { AuditLog } from '../../lib/stack/auditlog'
+import { Webhook } from '../../lib/stack/webhook'
+import { bindModuleHeaders, bindHeaderTarget } from '../../lib/core/moduleHeaderSupport.js'
+import { stackHeadersMock, entryMock } from './mock/objects'
+
+describe('Module header injection (addHeader / addHeaderDict / removeHeader)', () => {
+ it('Stack addHeader mutates shared stackHeaders visible on child modules', (done) => {
+ const stack = new Stack(Axios, { stack: { api_key: 'MY_KEY' } })
+ stack.addHeader('api_version', '3.2')
+ expect(stack.stackHeaders.api_version).to.equal('3.2')
+ const ct = stack.contentType()
+ expect(ct.stackHeaders).to.equal(stack.stackHeaders)
+ expect(ct.stackHeaders.api_version).to.equal('3.2')
+ done()
+ })
+
+ it('Stack addHeaderDict merges into stackHeaders and preserves api_key and branch', (done) => {
+ const stack = new Stack(Axios, { stack: { api_key: 'MY_KEY', branch_uid: 'dev_branch' } })
+ stack.addHeaderDict({ api_version: '3.2', custom: 'x' })
+ expect(stack.stackHeaders.api_key).to.equal('MY_KEY')
+ expect(stack.stackHeaders.branch).to.equal('dev_branch')
+ expect(stack.stackHeaders.api_version).to.equal('3.2')
+ expect(stack.stackHeaders.custom).to.equal('x')
+ done()
+ })
+
+ it('Resource addHeader isolates headers from sibling instances (copy-on-write)', (done) => {
+ const stack = new Stack(Axios, { stack: { api_key: 'MY_KEY' } })
+ const a = stack.asset()
+ const b = stack.asset()
+ expect(a.stackHeaders).to.equal(b.stackHeaders)
+ a.addHeader('x_module', 'only_a')
+ expect(a.stackHeaders.x_module).to.equal('only_a')
+ expect(b.stackHeaders.x_module).to.be.undefined
+ done()
+ })
+
+ it('Resource addHeaderDict does not mutate stack or sibling module headers', (done) => {
+ const stack = new Stack(Axios, { stack: { api_key: 'MY_KEY' } })
+ const w = stack.webhook()
+ w.addHeaderDict({ branch: 'dev', api_version: '3.1' })
+ expect(stack.stackHeaders.branch).to.be.undefined
+ expect(stack.stackHeaders.api_version).to.be.undefined
+ const other = stack.webhook()
+ expect(other.stackHeaders.branch).to.be.undefined
+ expect(other.stackHeaders.api_version).to.be.undefined
+ expect(w.stackHeaders.branch).to.equal('dev')
+ expect(w.stackHeaders.api_version).to.equal('3.1')
+ done()
+ })
+
+ it('Stack addHeader ignores null and undefined keys', (done) => {
+ const stack = new Stack(Axios, { stack: { api_key: 'K' } })
+ const before = { ...stack.stackHeaders }
+ stack.addHeader(null, 'v')
+ stack.addHeader(undefined, 'v')
+ expect(stack.stackHeaders).to.deep.equal(before)
+ done()
+ })
+
+ it('Resource addHeader ignores null key', (done) => {
+ const stack = new Stack(Axios, { stack: { api_key: 'K' } })
+ const asset = stack.asset()
+ asset.addHeader(null, 'v')
+ expect(asset.stackHeaders).to.deep.equal(stack.stackHeaders)
+ done()
+ })
+
+ it('Stack without api_key can use addHeader to initialize stackHeaders', (done) => {
+ const stack = new Stack(Axios, { organization_uid: 'org' })
+ expect(stack.stackHeaders).to.be.undefined
+ stack.addHeader('custom_header', '1')
+ expect(stack.stackHeaders).to.deep.equal({ custom_header: '1' })
+ done()
+ })
+
+ it('addHeader and addHeaderDict return instance for chaining', (done) => {
+ const stack = new Stack(Axios, { stack: { api_key: 'K' } })
+ expect(stack.addHeader('a', 1)).to.equal(stack)
+ const asset = stack.asset()
+ expect(asset.addHeaderDict({ b: 2 })).to.equal(asset)
+ done()
+ })
+
+ it('Query find sends headers from addHeader and addHeaderDict', (done) => {
+ const mock = new MockAdapter(Axios)
+ mock.onGet('/query-entries').reply(200, { entries: [entryMock] })
+ const q = Query(
+ Axios,
+ '/query-entries',
+ {},
+ { ...stackHeadersMock },
+ EntryCollection
+ )
+ q.addHeader('branch', 'release').addHeaderDict({ api_version: '3.2' })
+ q.find()
+ .then(() => {
+ expect(mock.history.get.length).to.be.at.least(1)
+ const req = mock.history.get[0]
+ expect(req.headers.branch).to.equal('release')
+ expect(req.headers.api_version).to.equal('3.2')
+ expect(req.headers.api_key).to.equal(stackHeadersMock.api_key)
+ done()
+ })
+ .catch(done)
+ })
+
+ it('Query with no initial stackHeaders supports addHeader on empty header map', (done) => {
+ const mock = new MockAdapter(Axios)
+ mock.onGet('/things').reply(200, { entries: [] })
+ const q = Query(Axios, '/things', null, null, EntryCollection)
+ q.addHeader('api_key', 'key_from_query_only')
+ q.find()
+ .then(() => {
+ const req = mock.history.get[0]
+ expect(req.headers.api_key).to.equal('key_from_query_only')
+ done()
+ })
+ .catch(done)
+ })
+
+ it('AuditLog fetch includes injected headers in the request', (done) => {
+ const mock = new MockAdapter(Axios)
+ mock.onGet('/audit-logs/UID').reply(200, { logs: { uid: 'UID' } })
+ const log = new AuditLog(Axios, {
+ logs: { uid: 'UID' },
+ stackHeaders: { ...stackHeadersMock }
+ })
+ log.addHeader('branch', 'staging')
+ log.fetch()
+ .then(() => {
+ const req = mock.history.get[0]
+ expect(req.headers.branch).to.equal('staging')
+ expect(req.headers.api_key).to.equal(stackHeadersMock.api_key)
+ done()
+ })
+ .catch(done)
+ })
+
+ it('Webhook fetchAll includes injected headers in the request', (done) => {
+ const mock = new MockAdapter(Axios)
+ mock.onGet('/webhooks').reply(200, { webhooks: [] })
+ const w = new Webhook(Axios, { stackHeaders: { ...stackHeadersMock } })
+ w.addHeader('api_version', '3.2')
+ w.fetchAll()
+ .then(() => {
+ const req = mock.history.get[0]
+ expect(req.headers.api_version).to.equal('3.2')
+ expect(req.headers.api_key).to.equal(stackHeadersMock.api_key)
+ done()
+ })
+ .catch(done)
+ })
+
+ it('bindHeaderTarget mutates the header map from getHeaderMap', (done) => {
+ const headers = { a: 1 }
+ const target = {}
+ bindHeaderTarget(target, () => headers)
+ target.addHeader('b', 2).addHeaderDict({ c: 3 })
+ expect(headers).to.deep.equal({ a: 1, b: 2, c: 3 })
+ expect(target.addHeader(null, 'x')).to.equal(target)
+ expect(headers).to.deep.equal({ a: 1, b: 2, c: 3 })
+ done()
+ })
+
+ it('bindModuleHeaders without ownsHeadersInline clones before first mutation', (done) => {
+ const shared = { api_key: 'k' }
+ const mod = { stackHeaders: shared }
+ bindModuleHeaders(mod)
+ mod.addHeader('z', 1)
+ expect(mod.stackHeaders.z).to.equal(1)
+ expect(shared.z).to.be.undefined
+ done()
+ })
+
+ it('Stack removeHeader deletes from shared stackHeaders for all child modules', (done) => {
+ const stack = new Stack(Axios, { stack: { api_key: 'MY_KEY' } })
+ stack.addHeader('api_version', '3.2')
+ stack.removeHeader('api_version')
+ expect(stack.stackHeaders.api_version).to.be.undefined
+ const ct = stack.contentType()
+ expect(ct.stackHeaders).to.equal(stack.stackHeaders)
+ expect(ct.stackHeaders.api_version).to.be.undefined
+ done()
+ })
+
+ it('Resource removeHeader only affects that instance when removing inherited key', (done) => {
+ const stack = new Stack(Axios, { stack: { api_key: 'MY_KEY', branch_uid: 'main' } })
+ const a = stack.asset()
+ const b = stack.asset()
+ a.removeHeader('branch')
+ expect(a.stackHeaders.branch).to.be.undefined
+ expect(b.stackHeaders.branch).to.equal('main')
+ expect(stack.stackHeaders.branch).to.equal('main')
+ done()
+ })
+
+ it('Resource removeHeader after addHeader drops only that header', (done) => {
+ const stack = new Stack(Axios, { stack: { api_key: 'K' } })
+ const w = stack.webhook()
+ w.addHeaderDict({ x: '1', y: '2' })
+ w.removeHeader('x')
+ expect(w.stackHeaders.x).to.be.undefined
+ expect(w.stackHeaders.y).to.equal('2')
+ expect(w.stackHeaders.api_key).to.equal('K')
+ done()
+ })
+
+ it('Resource removeHeader for missing key does not force copy-on-write', (done) => {
+ const stack = new Stack(Axios, { stack: { api_key: 'K' } })
+ const a = stack.asset()
+ const b = stack.asset()
+ a.removeHeader('nonexistent_key')
+ expect(a.stackHeaders).to.equal(b.stackHeaders)
+ done()
+ })
+
+ it('Stack and resource removeHeader ignore null key', (done) => {
+ const stack = new Stack(Axios, { stack: { api_key: 'K' } })
+ const before = { ...stack.stackHeaders }
+ stack.removeHeader(null)
+ expect(stack.stackHeaders).to.deep.equal(before)
+ const asset = stack.asset()
+ asset.removeHeader(undefined)
+ expect(asset.stackHeaders).to.deep.equal(stack.stackHeaders)
+ done()
+ })
+
+ it('Query find omits header after removeHeader', (done) => {
+ const mock = new MockAdapter(Axios)
+ mock.onGet('/q').reply(200, { entries: [] })
+ const q = Query(Axios, '/q', {}, { ...stackHeadersMock, branch: 'dev' }, EntryCollection)
+ q.removeHeader('branch')
+ q.find()
+ .then(() => {
+ const req = mock.history.get[0]
+ expect(req.headers.branch).to.be.undefined
+ expect(req.headers.api_key).to.equal(stackHeadersMock.api_key)
+ done()
+ })
+ .catch(done)
+ })
+
+ it('bindHeaderTarget removeHeader deletes key', (done) => {
+ const headers = { a: 1, b: 2 }
+ const target = {}
+ bindHeaderTarget(target, () => headers)
+ target.removeHeader('a')
+ expect(headers).to.deep.equal({ b: 2 })
+ expect(target.removeHeader(null)).to.equal(target)
+ done()
+ })
+})