fix(llmo): guard createOrFindSite from moving site with active enroll…#2491
fix(llmo): guard createOrFindSite from moving site with active enroll…#2491Kanishkavijay39 wants to merge 2 commits into
Conversation
…ments If the site already belongs to a different org and has active SiteEnrollments, skip the org reassignment and throw an error instead of silently moving it. Sites with no active enrollments are still re-parented as before (LLMO-4176 behaviour preserved).
|
This PR will trigger a patch release when merged. |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
MysticatBot
left a comment
There was a problem hiding this comment.
Hey @Kanishkavijay39,
Strengths
- Guard placement is correct (
src/controllers/llmo/llmo-onboarding.js:999-1002): The enrollment check is inserted beforesetOrganizationIdandsave, so the guard prevents any partial mutation. A failed check leaves the site object completely unmodified. - Well-shaped test coverage (
test/controllers/llmo/llmo-onboarding.test.js:1260-1291): The new test asserts both the positive (exception thrown with meaningful message) and the negative (setOrganizationIdandsavewere NOT called). The existing re-parent test is updated to stubgetSiteEnrollmentsreturning empty, confirming the happy path still works. - Consistency with existing platform behavior: This mirrors the guard already present in PLG onboarding (
plg-onboarding.js:1050-1058), aligning both onboarding paths to the same invariant. - Well-scoped change: One behavioral fix, one new test, one existing-test fixup. No unrelated refactoring mixed in.
Issues
Important (Should Fix)
Optional chaining on enrollments treats unexpected undefined/null as "no enrollments" - src/controllers/llmo/llmo-onboarding.js:1000
enrollments?.length > 0 - if getSiteEnrollments() resolves to undefined or null (e.g., a bug in the data-access layer where a model method returns nothing instead of an empty array), the optional chaining silently evaluates to false and the re-parent proceeds. This is the exact scenario the guard is meant to prevent: a site with active products gets moved because the enrollment check was inconclusive.
For a guard whose explicit purpose is to prevent data corruption, the failure mode should be loud (fail closed), not permissive (fail open). Suggested fix:
const enrollments = await site.getSiteEnrollments();
if (!Array.isArray(enrollments)) {
throw new Error(`Unable to verify enrollments for site ${baseURL}; aborting org move.`);
}
if (enrollments.length > 0) {
throw new Error(`Site ${baseURL} is already associated with another org that has active products and cannot be moved.`);
}If the team has established that getSiteEnrollments is contractually guaranteed to always return an array, this becomes less pressing - but given that this guard exists specifically to prevent data corruption, failing closed is the safer posture.
Recommendations
- Include org IDs in the error message for operational debuggability: The error currently includes
baseURLbut not the current or requested org IDs. When this surfaces in logs or Slack notifications, operators need to look up both orgs separately. Consider:Site ${baseURL} belongs to org ${site.getOrganizationId()} with active enrollments and cannot be moved to org ${organizationId}. - Consider a typed/coded error: Callers further up the stack may want to distinguish "site has enrollments" from other errors programmatically. A custom error code (e.g.,
err.code = 'SITE_HAS_ACTIVE_ENROLLMENTS') allows upstream handlers to present appropriate user-facing feedback without string-matching.
Assessment
Ready to merge? With fixes
Reasoning: The core logic is correct, the change is minimal and focused, and the test coverage is solid. The optional-chaining concern is worth addressing because a safety guard that fails open under unexpected data-layer behavior contradicts the PR's stated intent. The fix is a one-line addition.
Next Steps
- Address the optional chaining concern to ensure the guard fails closed on unexpected return values.
- The recommendations (richer error message, error code) are optional improvements.
| if (site) { | ||
| if (site.getOrganizationId() !== organizationId) { | ||
| const enrollments = await site.getSiteEnrollments(); | ||
| if (enrollments?.length > 0) { |
There was a problem hiding this comment.
Important: enrollments?.length > 0 - if getSiteEnrollments() resolves to undefined or null (data-access bug, model method returning nothing), the optional chaining silently evaluates to false and the re-parent proceeds. For a guard whose purpose is preventing data corruption, the failure mode should be loud:
const enrollments = await site.getSiteEnrollments();
if (!Array.isArray(enrollments)) {
throw new Error(`Unable to verify enrollments for site ${baseURL}; aborting org move.`);
}
if (enrollments.length > 0) {
throw new Error(`Site ${baseURL} is already associated with another org that has active products and cannot be moved.`);
}If the contract guarantees an array, the optional chaining can be dropped in favor of plain .length > 0 so a contract violation surfaces loudly.
Summary:
Please ensure your pull request adheres to the following guidelines:
describe here the problem you're solving.
If the PR is changing the API specification:
yet. Ideally, return a 501 status code with a message explaining the feature is not implemented yet.
If the PR is changing the API implementation or an entity exposed through the API:
If the PR is introducing a new audit type:
Related Issues
Thanks for contributing!