Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions src/controllers/sites.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,32 @@ async function buildResolveData(org, site, context) {
};
}

/**
* Recursively deep-merges `patch` into `base`, returning a new object.
*
* Merge semantics (used for PATCH of nested config like hlxConfig/deliveryConfig):
* - Omitted keys keep their existing value in `base`.
* - A `null` value deletes the key.
* - Plain objects are merged recursively; arrays and non-object values replace.
*
* @param {object} base - Existing value.
* @param {object} patch - Incoming partial patch.
* @returns {object} Merged result.
*/
function deepMerge(base, patch) {
const result = { ...(isObject(base) ? base : {}) };
for (const [key, value] of Object.entries(patch)) {
if (value === null) {
delete result[key];
} else if (isObject(value) && isObject(result[key])) {
result[key] = deepMerge(result[key], value);
} else {
result[key] = value;
}
}
return result;
}

/**
* Resolves the org's per-product default site from config.defaults, validating it belongs
* to the org and is enrolled. Returns the resolved data object or null to fall through
Expand Down Expand Up @@ -863,12 +889,15 @@ function SitesController(ctx, log, env) {
? requestBody.authoringType
: site.getAuthoringType();

// Deep-merge `deliveryConfig`/`hlxConfig` so a partial patch (e.g. hlxConfig with only
// rso+code) preserves omitted sub-keys like hlxConfig.content.source. An explicit `null`
// for a sub-key deletes it; omitting the field entirely keeps the existing value.
const nextDeliveryConfig = isObject(requestBody.deliveryConfig)
? requestBody.deliveryConfig
? deepMerge(site.getDeliveryConfig(), requestBody.deliveryConfig)
: site.getDeliveryConfig();

const nextHlxConfig = isObject(requestBody.hlxConfig)
? requestBody.hlxConfig
? deepMerge(site.getHlxConfig(), requestBody.hlxConfig)
: site.getHlxConfig();

const authoringTypeChanged = nextAuthoringType !== site.getAuthoringType();
Expand Down
97 changes: 97 additions & 0 deletions test/controllers/sites.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -715,6 +715,103 @@ describe('Sites Controller', () => {
});
});

it('deep-merges hlxConfig, preserving existing content.source on partial patch', async () => {
const site = sites[0];
site.setHlxConfig({
content: { source: { type: 'markup', url: 'https://content.example/' } },
code: { source: { type: 'github', url: 'https://github.com/old/repo' } },
});
site.save = sandbox.spy(site.save);

const response = await sitesController.updateSite({
params: { siteId: SITE_IDS[0] },
data: {
hlxConfig: {
rso: { owner: 'newOwner', site: 'newSite' },
code: { source: { type: 'github', url: 'https://github.com/new/repo' } },
},
},
...defaultAuthAttributes,
});

expect(site.save).to.have.been.calledOnce;
expect(response.status).to.equal(200);
const updatedSite = await response.json();
expect(updatedSite.hlxConfig).to.deep.equal({
content: { source: { type: 'markup', url: 'https://content.example/' } },
code: { source: { type: 'github', url: 'https://github.com/new/repo' } },
rso: { owner: 'newOwner', site: 'newSite' },
});
});

it('deletes an hlxConfig sub-key when patched with null', async () => {
const site = sites[0];
site.setHlxConfig({
content: { source: { type: 'markup', url: 'https://content.example/' } },
rso: { owner: 'owner', site: 'site' },
});
site.save = sandbox.spy(site.save);

const response = await sitesController.updateSite({
params: { siteId: SITE_IDS[0] },
data: { hlxConfig: { content: null } },
...defaultAuthAttributes,
});

expect(site.save).to.have.been.calledOnce;
expect(response.status).to.equal(200);
const updatedSite = await response.json();
expect(updatedSite.hlxConfig).to.deep.equal({
rso: { owner: 'owner', site: 'site' },
});
});

it('leaves hlxConfig unchanged when the patch omits it', async () => {
const site = sites[0];
const existingHlxConfig = {
content: { source: { type: 'markup', url: 'https://content.example/' } },
};
site.setHlxConfig(existingHlxConfig);
site.save = sandbox.spy(site.save);

const response = await sitesController.updateSite({
params: { siteId: SITE_IDS[0] },
data: { deliveryType: 'other' },
...defaultAuthAttributes,
});

expect(site.save).to.have.been.calledOnce;
expect(response.status).to.equal(200);
const updatedSite = await response.json();
expect(updatedSite.hlxConfig).to.deep.equal(existingHlxConfig);
});

it('deep-merges deliveryConfig, preserving omitted sub-keys on partial patch', async () => {
const site = sites[0];
site.setDeliveryConfig({
programId: '12652',
environmentId: '16854',
authorURL: 'https://author-p12652-e16854-cmstg.adobeaemcloud.com/',
});
site.save = sandbox.spy(site.save);

const response = await sitesController.updateSite({
params: { siteId: SITE_IDS[0] },
data: { deliveryConfig: { siteId: '1234' } },
...defaultAuthAttributes,
});

expect(site.save).to.have.been.calledOnce;
expect(response.status).to.equal(200);
const updatedSite = await response.json();
expect(updatedSite.deliveryConfig).to.deep.equal({
programId: '12652',
environmentId: '16854',
authorURL: 'https://author-p12652-e16854-cmstg.adobeaemcloud.com/',
siteId: '1234',
});
});

it('returns forbidden when trying to update organizationId', async () => {
const site = sites[0];
site.save = sandbox.spy(site.save);
Expand Down
Loading