-
Notifications
You must be signed in to change notification settings - Fork 0
fix: site-level handler overrides were ignored, org-level disable always won #1590
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -174,6 +174,21 @@ class Configuration { | |
| .map((job) => job.type); | ||
| } | ||
|
|
||
| /** | ||
| * Returns whether a handler is enabled for a given site. | ||
| * | ||
| * Precedence (highest to lowest): | ||
| * 1. disabled.sites includes siteId → false (site explicitly disabled) | ||
| * 2. enabled.sites includes siteId → true (site explicitly enabled; overrides org-level) | ||
| * 3. disabled.orgs includes orgId → false (org disabled, no site-level override present) | ||
| * 4. enabledByDefault → true | ||
| * 5. enabled.orgs includes orgId → true | ||
| * 6. otherwise → false | ||
| * | ||
| * Note: disabled.sites and enabled.sites are assumed disjoint; disabled.sites wins if violated | ||
| * (see #updatedHandler which keeps these lists disjoint on every write). | ||
| * isHandlerEnabledForOrg uses a different (simpler) precedence — see its JSDoc. | ||
| */ | ||
| isHandlerEnabledForSite(type, site) { | ||
| const handler = this.getHandlers()?.[type]; | ||
| if (!handler) { | ||
|
|
@@ -183,26 +198,53 @@ class Configuration { | |
| const siteId = site.getId(); | ||
| const orgId = site.getOrganizationId(); | ||
|
|
||
| if (handler.disabled) { | ||
| const sites = handler.disabled.sites || []; | ||
| const orgs = handler.disabled.orgs || []; | ||
| if (sites.includes(siteId) || orgs.includes(orgId)) { | ||
| return false; | ||
| const disabledSites = handler.disabled?.sites || []; | ||
| const enabledSites = handler.enabled?.sites || []; | ||
| const disabledOrgs = handler.disabled?.orgs || []; | ||
| const enabledOrgs = handler.enabled?.orgs || []; | ||
|
|
||
| // Site-level takes priority over org-level: an explicit site enable/disable | ||
| // overrides the org-level setting, allowing a single site to be upgraded to | ||
| // a paid profile even if the org is in disabled.orgs. | ||
| if (disabledSites.includes(siteId)) { | ||
| return false; | ||
| } | ||
| if (enabledSites.includes(siteId)) { | ||
| if (disabledOrgs.includes(orgId)) { | ||
| this.log.info('[isHandlerEnabledForSite] site-override: site in enabled.sites overrides org in disabled.orgs', { type, siteId, orgId }); | ||
| } | ||
| return true; | ||
| } | ||
|
|
||
| if (disabledOrgs.includes(orgId)) { | ||
| return false; | ||
| } | ||
| if (handler.enabledByDefault) { | ||
| return true; | ||
| } | ||
| if (handler.enabled) { | ||
| const sites = handler.enabled.sites || []; | ||
| const orgs = handler.enabled.orgs || []; | ||
| return sites.includes(siteId) || orgs.includes(orgId); | ||
| } | ||
|
|
||
| return false; | ||
| return enabledOrgs.includes(orgId); | ||
| } | ||
|
|
||
| /** | ||
| * Returns whether a handler is enabled for a given org. | ||
| * | ||
| * Precedence (highest to lowest): | ||
| * 1. disabled.orgs includes orgId → false | ||
| * 2. enabledByDefault → true | ||
| * 3. enabled.orgs includes orgId → true | ||
| * 4. otherwise → false | ||
| * | ||
| * Note: unlike isHandlerEnabledForSite, there is no site-level override here. | ||
| * An org in disabled.orgs is always disabled regardless of site-level entries. | ||
| * | ||
| * Cross-system consequence: this method is the gate for org-scoped fan-out | ||
| * (e.g. report digests keyed on orgId in spacecat-jobs-dispatcher and | ||
| * spacecat-reporting-worker). A paid site in enabled.sites whose org is in | ||
| * disabled.orgs will receive site-targeted handler evaluation (true via | ||
| * isHandlerEnabledForSite) but will NOT receive org-level digest payloads | ||
| * (false here). This asymmetry is intentional: org-level reports follow org | ||
| * policy; site-level overrides do not propagate to org-scoped fan-out. | ||
| */ | ||
| isHandlerEnabledForOrg(type, org) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Important: cross-system consequence of the asymmetric model is not documented. Tracing the consumer call sites: Implication: a paid site in This is an in-repo doc fix. Pick one:
Either is defensible. The asymmetric model is documented at the method level but the cross-system consequence is invisible today. |
||
| const handler = this.getHandlers()?.[type]; | ||
| if (!handler) { | ||
|
|
@@ -243,20 +285,21 @@ class Configuration { | |
| } | ||
|
|
||
| if (enabled) { | ||
| if (handler.enabledByDefault) { | ||
| handler.disabled[entityKey] = handler.disabled[entityKey] | ||
| /* c8 ignore next */ | ||
| .filter((id) => id !== entityId) || []; | ||
| } else { | ||
| handler.enabled[entityKey] = Array | ||
| .from(new Set([...(handler.enabled[entityKey] || []), entityId])); | ||
| } | ||
| // Always remove from disabled first (handles re-enabling a previously disabled entry). | ||
| handler.disabled[entityKey] = (handler.disabled[entityKey] || []) | ||
| .filter((id) => id !== entityId); | ||
| // Always add to enabled so isHandlerEnabledForSite can find the site in enabled.sites | ||
| // and return true even when the org is in disabled.orgs (site-level override). | ||
| handler.enabled[entityKey] = Array | ||
| .from(new Set([...(handler.enabled[entityKey] || []), entityId])); | ||
| } else if (handler.enabledByDefault) { | ||
| handler.disabled[entityKey] = Array | ||
| .from(new Set([...(handler.disabled[entityKey] || []), entityId])); | ||
| handler.enabled[entityKey] = (handler.enabled[entityKey] || []) | ||
| .filter((id) => id !== entityId); | ||
| } else { | ||
| /* c8 ignore next */ | ||
| handler.enabled[entityKey] = handler.enabled[entityKey].filter((id) => id !== entityId) || []; | ||
| handler.enabled[entityKey] = (handler.enabled[entityKey] || []) | ||
| .filter((id) => id !== entityId); | ||
| } | ||
|
|
||
| handlers[type] = handler; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -235,6 +235,98 @@ describe('ConfigurationModel', () => { | |
| expect(isEnabled).to.be.true; | ||
| }); | ||
|
|
||
| it('returns false for a site whose org is in disabled.orgs and the site is not explicitly listed', () => { | ||
| instance.addHandler('org-disabled-handler', { | ||
| enabledByDefault: true, | ||
| enabled: { sites: [], orgs: [] }, | ||
| disabled: { sites: [], orgs: [site.getOrganizationId()] }, | ||
| }); | ||
|
|
||
| expect(instance.isHandlerEnabledForSite('org-disabled-handler', site)).to.be.false; | ||
| }); | ||
|
|
||
|
tkotthakota-adobe marked this conversation as resolved.
|
||
| it('returns true when site is in enabled.sites even if its org is in disabled.orgs', () => { | ||
| instance.addHandler('site-override-handler', { | ||
| enabledByDefault: true, | ||
|
tkotthakota-adobe marked this conversation as resolved.
|
||
| enabled: { sites: [site.getId()], orgs: [] }, | ||
| disabled: { sites: [], orgs: [site.getOrganizationId()] }, | ||
| }); | ||
|
|
||
| expect(instance.isHandlerEnabledForSite('site-override-handler', site)).to.be.true; | ||
| }); | ||
|
|
||
| it('returns true for non-default handler when site is in enabled.sites and org is in disabled.orgs', () => { | ||
| instance.addHandler('paid-handler', { | ||
| enabledByDefault: false, | ||
| enabled: { sites: [site.getId()], orgs: [] }, | ||
| disabled: { sites: [], orgs: [site.getOrganizationId()] }, | ||
| }); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Important - the headline production scenario lacks an end-to-end test. The PR exists to fix the free-trial -> paid upgrade path: org has handler X with Without a test that combines write + read, a future regression that breaks it('upgrades non-default handler from disabled org via updateHandlerSites', () => {
instance.addHandler('paid-handler', {
enabledByDefault: false,
enabled: { sites: [], orgs: [] },
disabled: { sites: [], orgs: [site.getOrganizationId()] },
});
instance.updateHandlerSites('paid-handler', site.getId(), true);
expect(instance.isHandlerEnabledForSite('paid-handler', site)).to.be.true;
});This is the test that tells future maintainers what the PR is for. |
||
|
|
||
| expect(instance.isHandlerEnabledForSite('paid-handler', site)).to.be.true; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Important: headline end-to-end test missing (Round 2 unresolved). The PR fixes the free-trial -> paid upgrade path, but no test composes write-path-then-read-path with the full org-disabled context. Test 3 (this test) pre-populates Fix: one chained test, approximately 10 lines: it('enables a paid handler for a site whose org is in disabled.orgs (free-trial -> paid)', () => {
instance.addHandler('paid-handler', {
enabledByDefault: false,
enabled: { sites: [], orgs: [] },
disabled: { sites: [], orgs: [site.getOrganizationId()] },
});
expect(instance.isHandlerEnabledForSite('paid-handler', site)).to.be.false;
instance.updateHandlerSites('paid-handler', site.getId(), true);
expect(instance.isHandlerEnabledForSite('paid-handler', site)).to.be.true;
expect(instance.getHandler('paid-handler').enabled.sites).to.include(site.getId());
}); |
||
| }); | ||
|
|
||
| it('enables a paid handler for a site whose org is in disabled.orgs (free-trial -> paid)', () => { | ||
| instance.addHandler('paid-handler', { | ||
| enabledByDefault: false, | ||
| enabled: { sites: [], orgs: [] }, | ||
| disabled: { sites: [], orgs: [site.getOrganizationId()] }, | ||
| }); | ||
|
|
||
| expect(instance.isHandlerEnabledForSite('paid-handler', site)).to.be.false; | ||
| instance.updateHandlerSites('paid-handler', site.getId(), true); | ||
| expect(instance.isHandlerEnabledForSite('paid-handler', site)).to.be.true; | ||
| expect(instance.getHandler('paid-handler').enabled.sites).to.include(site.getId()); | ||
| }); | ||
|
|
||
| it('removes site from disabled.sites when re-enabling for a non-default handler', () => { | ||
| instance.addHandler('cleanup-handler', { | ||
| enabledByDefault: false, | ||
| enabled: { sites: [], orgs: [] }, | ||
| disabled: { sites: [site.getId()], orgs: [] }, | ||
| }); | ||
|
|
||
| instance.updateHandlerSites('cleanup-handler', site.getId(), true); | ||
|
|
||
| expect(instance.getHandler('cleanup-handler').disabled.sites).to.not.include(site.getId()); | ||
| expect(instance.getHandler('cleanup-handler').enabled.sites).to.include(site.getId()); | ||
| }); | ||
|
|
||
| it('removes site from enabled.sites when disabling a default handler', () => { | ||
| instance.addHandler('default-cleanup-handler', { | ||
| enabledByDefault: true, | ||
| enabled: { sites: [site.getId()], orgs: [] }, | ||
| disabled: { sites: [], orgs: [] }, | ||
| }); | ||
|
|
||
| instance.updateHandlerSites('default-cleanup-handler', site.getId(), false); | ||
|
|
||
| expect(instance.getHandler('default-cleanup-handler').enabled.sites).to.not.include(site.getId()); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Important -
With the new "always add to enabled on enable" change in this PR, the asymmetry of branch 3 is now load-bearing: a paid handler that gets enabled (now in it('removes site from enabled.sites when disabling for a non-default handler', () => {
instance.addHandler('paid-disable-handler', {
enabledByDefault: false,
enabled: { sites: [site.getId()], orgs: [] },
disabled: { sites: [], orgs: [] },
});
instance.updateHandlerSites('paid-disable-handler', site.getId(), false);
expect(instance.getHandler('paid-disable-handler').enabled.sites).to.not.include(site.getId());
expect(instance.getHandler('paid-disable-handler').disabled.sites).to.not.include(site.getId());
});This pins the contract that disabling a non-default handler is "not in either list" (default-off applies), distinct from disabling a default handler ("in disabled list, default-on overridden"). |
||
| expect(instance.getHandler('default-cleanup-handler').disabled.sites).to.include(site.getId()); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Important: The existing test at line 349-353 exercises this branch through With this PR's "always add to enabled on enable" change, a paid handler can land in Fix: add |
||
| }); | ||
|
|
||
| it('returns false when site is in both enabled.sites and disabled.sites (disabled.sites wins)', () => { | ||
| instance.addHandler('conflict-handler', { | ||
| enabledByDefault: false, | ||
| enabled: { sites: [site.getId()], orgs: [] }, | ||
| disabled: { sites: [site.getId()], orgs: [] }, | ||
| }); | ||
|
|
||
| expect(instance.isHandlerEnabledForSite('conflict-handler', site)).to.be.false; | ||
| }); | ||
|
|
||
| it('enables a site idempotently (repeated enable does not grow enabled.sites)', () => { | ||
| instance.addHandler('idempotent-handler', { | ||
| enabledByDefault: false, | ||
| enabled: { sites: [], orgs: [] }, | ||
| disabled: { sites: [], orgs: [] }, | ||
| }); | ||
|
|
||
| instance.updateHandlerSites('idempotent-handler', site.getId(), true); | ||
| instance.updateHandlerSites('idempotent-handler', site.getId(), true); | ||
|
|
||
| expect(instance.getHandler('idempotent-handler').enabled.sites.length).to.equal(1); | ||
| }); | ||
|
|
||
| it('updates handler orgs for a handler disabled by default with enabled', () => { | ||
| instance.updateHandlerOrgs('lhs-mobile', org.getId(), true); | ||
| expect(instance.getHandler('lhs-mobile').enabled.orgs).to.include(org.getId()); | ||
|
|
@@ -292,8 +384,10 @@ describe('ConfigurationModel', () => { | |
|
|
||
| it('disables a handler for a site', () => { | ||
| instance.enableHandlerForSite('organic-keywords', site); | ||
| expect(instance.getHandler('organic-keywords').enabled.sites).to.include(site.getId()); | ||
| instance.disableHandlerForSite('organic-keywords', site); | ||
| expect(instance.getHandler('organic-keywords').disabled.sites).to.not.include(site.getId()); | ||
| expect(instance.getHandler('organic-keywords').enabled.sites).to.not.include(site.getId()); | ||
| }); | ||
|
|
||
| it('enables a handler for an organization', () => { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Comment by @solaris007
Important: This flips existing config rows where a site is in enabled.sites and the org is in disabled.orgs from disabled to enabled at deploy time. Run a query against prod to enumerate affected rows before merging.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I do not have prod access @solaris007 What is the best way to do this query?