Skip to content

Commit e5a0de3

Browse files
authored
fix: adds additional check for dangling enrollments (#1296)
## Filter dangling enrollments in getAllEnrollment ### Summary Added validation to filter out dangling site enrollments - enrollments that reference sites belonging to a different organization than the entitlement. ### Changes - Fetches associated sites using `batchGetByKeys` after retrieving enrollments - Filters out dangling enrollments where site's orgId doesn't match entitlement's orgId - Logs warnings with details (enrollmentId, siteId, siteOrgId, expectedOrgId) for monitoring - Also handles orphaned enrollments where the site no longer exists ### Why Dangling enrollments can occur when sites are moved between organizations but their enrollments aren't cleaned up. This fix ensures the API returns only valid enrollments, preventing issues downstream. Please ensure your pull request adheres to the following guidelines: - [ ] make sure to link the related issues in this description - [ ] when merging / squashing, make sure the fixed issue references are visible in the commits, for easy compilation of release notes ## Related Issues Thanks for contributing!
1 parent 8c47acc commit e5a0de3

File tree

2 files changed

+173
-5
lines changed

2 files changed

+173
-5
lines changed

packages/spacecat-shared-tier-client/src/tier-client.js

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,7 @@ class TierClient {
220220
* Gets all enrollments based on context, filtered by productCode.
221221
* - If site is provided: returns site enrollment for the entitlement matching productCode
222222
* - If org-only: returns all site enrollments for the entitlement matching productCode
223+
* - Filters out enrollments where the site's orgId doesn't match the entitlement's orgId
223224
* @returns {Promise<object>} Object with entitlement and enrollments array.
224225
*/
225226
async getAllEnrollment() {
@@ -234,16 +235,41 @@ class TierClient {
234235

235236
const allEnrollments = await this.SiteEnrollment.allByEntitlementId(entitlement.getId());
236237

238+
if (allEnrollments.length === 0) {
239+
return { entitlement, enrollments: [] };
240+
}
241+
242+
// Fetch all sites using batchGetByKeys
243+
const siteKeys = allEnrollments.map((enrollment) => ({ siteId: enrollment.getSiteId() }));
244+
const sitesResult = await this.Site.batchGetByKeys(siteKeys);
245+
const sitesMap = new Map(sitesResult.data.map((site) => [site.getId(), site]));
246+
247+
// Filter enrollments where site's orgId matches the entitlement's orgId
248+
const validEnrollments = [];
249+
250+
for (const enrollment of allEnrollments) {
251+
const site = sitesMap.get(enrollment.getSiteId());
252+
if (!site) {
253+
// Site not found, log warning and skip
254+
this.log.warn(`Site not found for enrollment ${enrollment.getId()} with siteId ${enrollment.getSiteId()}`);
255+
} else {
256+
const siteOrgId = site.getOrganizationId();
257+
if (siteOrgId === orgId) {
258+
validEnrollments.push(enrollment);
259+
}
260+
}
261+
}
262+
237263
if (this.site) {
238264
// Return site enrollments matching the entitlement and site
239265
const siteId = this.site.getId();
240-
const matchingEnrollments = allEnrollments.filter(
266+
const matchingEnrollments = validEnrollments.filter(
241267
(se) => se.getSiteId() === siteId,
242268
);
243269
return { entitlement, enrollments: matchingEnrollments };
244270
} else {
245-
// Return all enrollments for the entitlement
246-
return { entitlement, enrollments: allEnrollments };
271+
// Return all valid enrollments for the entitlement
272+
return { entitlement, enrollments: validEnrollments };
247273
}
248274
} catch (error) {
249275
this.log.error(`Error getting all enrollments: ${error.message}`);

packages/spacecat-shared-tier-client/test/tier-client.test.js

Lines changed: 144 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ describe('TierClient', () => {
8080
},
8181
Site: {
8282
findById: sandbox.stub(),
83+
batchGetByKeys: sandbox.stub(),
8384
},
8485
};
8586

@@ -726,11 +727,25 @@ describe('TierClient', () => {
726727
getEntitlementId: () => 'entitlement-123',
727728
};
728729

730+
const mockSiteForEnrollment1 = {
731+
getId: () => siteId,
732+
getOrganizationId: () => orgId,
733+
};
734+
735+
const mockSiteForEnrollment2 = {
736+
getId: () => '789-site-id',
737+
getOrganizationId: () => orgId,
738+
};
739+
729740
mockDataAccess.Entitlement.findByOrganizationIdAndProductCode.resolves(mockEntitlement);
730741
mockDataAccess.SiteEnrollment.allByEntitlementId.resolves([
731742
mockSiteEnrollment,
732743
mockEnrollment2,
733744
]);
745+
mockDataAccess.Site.batchGetByKeys.resolves({
746+
data: [mockSiteForEnrollment1, mockSiteForEnrollment2],
747+
unprocessed: [],
748+
});
734749

735750
const result = await tierClientWithoutSite.getAllEnrollment();
736751

@@ -742,6 +757,10 @@ describe('TierClient', () => {
742757
.to.have.been.calledWith(orgId, productCode);
743758
expect(mockDataAccess.SiteEnrollment.allByEntitlementId)
744759
.to.have.been.calledWith('entitlement-123');
760+
expect(mockDataAccess.Site.batchGetByKeys).to.have.been.calledWith([
761+
{ siteId },
762+
{ siteId: '789-site-id' },
763+
]);
745764
});
746765

747766
it('should return filtered enrollments when site is provided', async () => {
@@ -751,11 +770,25 @@ describe('TierClient', () => {
751770
getEntitlementId: () => 'entitlement-123',
752771
};
753772

773+
const mockSiteForEnrollment1 = {
774+
getId: () => siteId,
775+
getOrganizationId: () => orgId,
776+
};
777+
778+
const mockSiteForEnrollment2 = {
779+
getId: () => 'other-site-id',
780+
getOrganizationId: () => orgId,
781+
};
782+
754783
mockDataAccess.Entitlement.findByOrganizationIdAndProductCode.resolves(mockEntitlement);
755784
mockDataAccess.SiteEnrollment.allByEntitlementId.resolves([
756785
mockSiteEnrollment,
757786
mockEnrollment2,
758787
]);
788+
mockDataAccess.Site.batchGetByKeys.resolves({
789+
data: [mockSiteForEnrollment1, mockSiteForEnrollment2],
790+
unprocessed: [],
791+
});
759792

760793
const result = await tierClient.getAllEnrollment();
761794

@@ -811,18 +844,96 @@ describe('TierClient', () => {
811844
getEntitlementId: () => 'entitlement-123',
812845
};
813846

847+
const mockSiteForEnrollment1 = {
848+
getId: () => siteId,
849+
getOrganizationId: () => orgId,
850+
};
851+
852+
const mockSiteForEnrollment2 = {
853+
getId: () => 'different-site-id',
854+
getOrganizationId: () => orgId,
855+
};
856+
814857
mockDataAccess.Entitlement.findByOrganizationIdAndProductCode.resolves(mockEntitlement);
815858
mockDataAccess.SiteEnrollment.allByEntitlementId.resolves([
816859
mockSiteEnrollment,
817860
mockEnrollment2,
818861
mockEnrollment3,
819862
]);
863+
mockDataAccess.Site.batchGetByKeys.resolves({
864+
data: [mockSiteForEnrollment1, mockSiteForEnrollment2, mockSiteForEnrollment1],
865+
unprocessed: [],
866+
});
820867

821868
const result = await tierClient.getAllEnrollment();
822869

823870
expect(result.enrollments).to.have.lengthOf(2);
824871
expect(result.enrollments).to.deep.equal([mockSiteEnrollment, mockEnrollment3]);
825872
});
873+
874+
it('should filter out enrollments with mismatching orgId', async () => {
875+
const tierClientWithoutSite = new TierClient(
876+
mockContext,
877+
organizationInstance,
878+
null,
879+
productCode,
880+
);
881+
882+
const mismatchingOrgId = 'different-org-id';
883+
const mockEnrollment2 = {
884+
getId: () => 'enrollment-456',
885+
getSiteId: () => 'site-with-wrong-org',
886+
getEntitlementId: () => 'entitlement-123',
887+
};
888+
889+
const mockSiteForEnrollment1 = {
890+
getId: () => siteId,
891+
getOrganizationId: () => orgId,
892+
};
893+
894+
const mockSiteForEnrollment2 = {
895+
getId: () => 'site-with-wrong-org',
896+
getOrganizationId: () => mismatchingOrgId,
897+
};
898+
899+
mockDataAccess.Entitlement.findByOrganizationIdAndProductCode.resolves(mockEntitlement);
900+
mockDataAccess.SiteEnrollment.allByEntitlementId.resolves([
901+
mockSiteEnrollment,
902+
mockEnrollment2,
903+
]);
904+
mockDataAccess.Site.batchGetByKeys.resolves({
905+
data: [mockSiteForEnrollment1, mockSiteForEnrollment2],
906+
unprocessed: [],
907+
});
908+
909+
const result = await tierClientWithoutSite.getAllEnrollment();
910+
911+
expect(result.enrollments).to.have.lengthOf(1);
912+
expect(result.enrollments[0].getId()).to.equal('enrollment-123');
913+
});
914+
915+
it('should log warning when site not found for enrollment', async () => {
916+
const tierClientWithoutSite = new TierClient(
917+
mockContext,
918+
organizationInstance,
919+
null,
920+
productCode,
921+
);
922+
923+
mockDataAccess.Entitlement.findByOrganizationIdAndProductCode.resolves(mockEntitlement);
924+
mockDataAccess.SiteEnrollment.allByEntitlementId.resolves([mockSiteEnrollment]);
925+
mockDataAccess.Site.batchGetByKeys.resolves({
926+
data: [],
927+
unprocessed: [],
928+
});
929+
930+
const result = await tierClientWithoutSite.getAllEnrollment();
931+
932+
expect(result.enrollments).to.have.lengthOf(0);
933+
expect(mockContext.log.warn).to.have.been.calledWith(
934+
`Site not found for enrollment ${mockSiteEnrollment.getId()} with siteId ${siteId}`,
935+
);
936+
});
826937
});
827938

828939
describe('getFirstEnrollment', () => {
@@ -834,10 +945,15 @@ describe('TierClient', () => {
834945
const mockSiteObject = {
835946
getId: () => siteId,
836947
getName: () => 'Test Site',
948+
getOrganizationId: () => orgId,
837949
};
838950

839951
mockDataAccess.Entitlement.findByOrganizationIdAndProductCode.resolves(mockEntitlement);
840952
mockDataAccess.SiteEnrollment.allByEntitlementId.resolves([mockSiteEnrollment]);
953+
mockDataAccess.Site.batchGetByKeys.resolves({
954+
data: [mockSiteObject],
955+
unprocessed: [],
956+
});
841957
mockDataAccess.Site.findById.resolves(mockSiteObject);
842958

843959
const tierClientWithoutSite = new TierClient(
@@ -861,6 +977,13 @@ describe('TierClient', () => {
861977
const mockSiteObject = {
862978
getId: () => siteId,
863979
getName: () => 'Test Site',
980+
getOrganizationId: () => orgId,
981+
};
982+
983+
const mockSiteObject2 = {
984+
getId: () => 'other-site-id',
985+
getName: () => 'Other Site',
986+
getOrganizationId: () => orgId,
864987
};
865988

866989
const mockEnrollment2 = {
@@ -874,6 +997,10 @@ describe('TierClient', () => {
874997
mockSiteEnrollment,
875998
mockEnrollment2,
876999
]);
1000+
mockDataAccess.Site.batchGetByKeys.resolves({
1001+
data: [mockSiteObject, mockSiteObject2],
1002+
unprocessed: [],
1003+
});
8771004
mockDataAccess.Site.findById.resolves(mockSiteObject);
8781005

8791006
const tierClientWithoutSite = new TierClient(
@@ -917,8 +1044,18 @@ describe('TierClient', () => {
9171044
});
9181045

9191046
it('should return null site when site not found in database', async () => {
1047+
const mockSiteObject = {
1048+
getId: () => siteId,
1049+
getName: () => 'Test Site',
1050+
getOrganizationId: () => orgId,
1051+
};
1052+
9201053
mockDataAccess.Entitlement.findByOrganizationIdAndProductCode.resolves(mockEntitlement);
9211054
mockDataAccess.SiteEnrollment.allByEntitlementId.resolves([mockSiteEnrollment]);
1055+
mockDataAccess.Site.batchGetByKeys.resolves({
1056+
data: [mockSiteObject],
1057+
unprocessed: [],
1058+
});
9221059
mockDataAccess.Site.findById.resolves(null);
9231060

9241061
const tierClientWithoutSite = new TierClient(
@@ -951,10 +1088,15 @@ describe('TierClient', () => {
9511088
const mockSiteObject = {
9521089
getId: () => siteId,
9531090
getName: () => 'Test Site',
1091+
getOrganizationId: () => orgId,
9541092
};
9551093

9561094
mockDataAccess.Entitlement.findByOrganizationIdAndProductCode.resolves(mockEntitlement);
9571095
mockDataAccess.SiteEnrollment.allByEntitlementId.resolves([mockSiteEnrollment]);
1096+
mockDataAccess.Site.batchGetByKeys.resolves({
1097+
data: [mockSiteObject],
1098+
unprocessed: [],
1099+
});
9581100
mockDataAccess.Site.findById.resolves(mockSiteObject);
9591101

9601102
const result = await tierClient.getFirstEnrollment();
@@ -966,10 +1108,10 @@ describe('TierClient', () => {
9661108
});
9671109
});
9681110

969-
it('should handle error when fetching site', async () => {
1111+
it('should handle error when fetching site via batchGetByKeys', async () => {
9701112
mockDataAccess.Entitlement.findByOrganizationIdAndProductCode.resolves(mockEntitlement);
9711113
mockDataAccess.SiteEnrollment.allByEntitlementId.resolves([mockSiteEnrollment]);
972-
mockDataAccess.Site.findById.rejects(new Error('Site fetch error'));
1114+
mockDataAccess.Site.batchGetByKeys.rejects(new Error('Site fetch error'));
9731115

9741116
await expect(tierClient.getFirstEnrollment()).to.be.rejectedWith('Site fetch error');
9751117
});

0 commit comments

Comments
 (0)