Skip to content

Commit 6c3d9ab

Browse files
author
Guy Pelletier
committed
fix(preflight): address SITES-48037 review — split null-org from empty-imsOrgId, empty-imsOrgId → 400
@MysticatBot (must-fix): null org and empty imsOrgId are different data-quality issues with different remediation paths — a null Organization means the site's FK is dangling (needs data cleanup), while an empty imsOrgId means the org exists but wasn't fully onboarded (needs config completion). Split into two distinct branches with distinct log messages and error responses. @bhellema: `!hasText(imsOrgId)` is now 400 (`PREFLIGHT_INVALID_REQUEST`), not 500. The empty imsOrgId is a config gap that the caller's admin has to resolve; it isn't a retryable server error. Kept `Organization.findById` throw (transient DB error) and `Organization not found` (broken FK) as 500 because those are genuinely server-side. @MysticatBot (non-blocking): cached `site.getOrganizationId()` in `orgId` to avoid re-calling the getter (and the theoretical re-throw risk in the catch block if the getter itself fails). Test resilience: swapped `expect(fetchStub.secondCall).to.be.null` for `expect(fetchStub).to.not.have.been.called` — cleaner and independent of the HEAD probe's positioning. Deferred (own follow-ups): - @johobot: hasAccess(site) already loads the Organization; explore whether the ORM caches it on the site entity for reuse via site.getOrganization(). Worth verifying caching semantics before threading it through. - @MysticatBot: callMysticatAnalyze positional-param count. Refactor to options-object; own PR to keep this one focused. Tests updated to match: - "empty imsOrgId" → 400 + PREFLIGHT_INVALID_REQUEST - "null org" → 500 + "Site organization not found" (distinct message) - Assertion swap: not.have.been.called instead of secondCall.to.be.null
1 parent f3c7325 commit 6c3d9ab

2 files changed

Lines changed: 43 additions & 25 deletions

File tree

src/controllers/preflight.js

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -528,22 +528,34 @@ function PreflightController(ctx, log, env) {
528528
// source of truth for the site → organization mapping; mysticat requires
529529
// us to supply it and does not fall back to any local projection.
530530
//
531-
// Fail-closed on Organization load failure or missing/empty imsOrgId —
532-
// mysticat's analyze route rejects the request with 422 if the field is
533-
// absent, so let-it-through-and-fail-late would just defer the same 500
534-
// by one hop. Better to surface the data issue here where the error
535-
// message can name the site and organization directly.
536-
let imsOrgId;
531+
// Three distinct failure modes, each with its own remediation path:
532+
// 1. Organization.findById throws (DB unreachable / transient) → 500;
533+
// retryable, alerting-worthy.
534+
// 2. Organization row not found (FK integrity issue — dangling
535+
// site.organization_id) → 500; not retryable, needs data cleanup.
536+
// 3. Organization exists but imsOrgId is empty/missing (config gap —
537+
// site is registered but its parent org wasn't fully onboarded) →
538+
// 400; not retryable by us, the caller's admin needs to populate the
539+
// org's imsOrgId before this site can preflight.
540+
//
541+
// Distinguishing (2) and (3) in the log message matters for triage —
542+
// they surface different data-quality issues with different owners.
543+
const orgId = site.getOrganizationId();
544+
let organization;
537545
try {
538-
const organization = await dataAccess.Organization.findById(site.getOrganizationId());
539-
imsOrgId = organization?.getImsOrgId();
546+
organization = await dataAccess.Organization.findById(orgId);
540547
} catch (e) {
541-
log.error(`[Preflight] Failed to load Organization ${site.getOrganizationId()} for site ${siteId}: ${e.message}`);
548+
log.error(`[Preflight] Failed to load Organization ${orgId} for site ${siteId}: ${e.message}`);
542549
return preflightError('PREFLIGHT_INTERNAL_ERROR', 'Failed to resolve site organization', 500);
543550
}
551+
if (!organization) {
552+
log.error(`[Preflight] Organization ${orgId} not found for site ${siteId} — dangling FK, needs data cleanup.`);
553+
return preflightError('PREFLIGHT_INTERNAL_ERROR', 'Site organization not found', 500);
554+
}
555+
const imsOrgId = organization.getImsOrgId();
544556
if (!hasText(imsOrgId)) {
545-
log.error(`[Preflight] Organization ${site.getOrganizationId()} for site ${siteId} has no imsOrgId — cannot proceed with preflight (mysticat requires it).`);
546-
return preflightError('PREFLIGHT_INTERNAL_ERROR', 'Site organization is missing imsOrgId', 500);
557+
log.error(`[Preflight] Organization ${orgId} for site ${siteId} has no imsOrgId — org not fully onboarded; cannot proceed with preflight (mysticat requires it).`);
558+
return preflightError('PREFLIGHT_INVALID_REQUEST', 'Site organization is missing imsOrgId', 400);
547559
}
548560

549561
// Validate the URL belongs to this site

test/controllers/preflight.test.js

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1649,11 +1649,11 @@ describe('Preflight Controller', () => {
16491649
expect(mockDataAccess.Organization.findById).to.have.been.calledWith('org-123');
16501650
});
16511651

1652-
it('returns 500 when the Organization has no imsOrgId (SITES-48037 fail-closed)', async () => {
1653-
// Empty imsOrgId cannot be forwarded to mysticat (which now rejects the
1654-
// request without it). Fail here where the log line can name the site
1655-
// and organization; deferring the same failure by one hop to mysticat's
1656-
// 422 wouldn't add diagnostic value.
1652+
it('returns 400 when the Organization has no imsOrgId (SITES-48037 fail-closed)', async () => {
1653+
// Empty imsOrgId is a config gap — the site is registered but its parent
1654+
// org wasn't fully onboarded. 400 (not 500) because this isn't a
1655+
// retryable server error; the caller's admin needs to populate the org's
1656+
// imsOrgId before this site can preflight.
16571657
mockDataAccess.Organization.findById.resolves({
16581658
getId: () => 'org-123',
16591659
getImsOrgId: () => '',
@@ -1663,12 +1663,15 @@ describe('Preflight Controller', () => {
16631663
data: { url: 'https://main--example-site.aem.page/test.html' },
16641664
attributes: { authInfo: mockAuthInfo },
16651665
});
1666-
expect(response.status).to.equal(500);
1666+
expect(response.status).to.equal(400);
16671667
const result = await response.json();
1668-
expect(result.errorCode).to.equal('PREFLIGHT_INTERNAL_ERROR');
1668+
expect(result.errorCode).to.equal('PREFLIGHT_INVALID_REQUEST');
16691669
expect(result.message).to.equal('Site organization is missing imsOrgId');
1670-
// Never reached the mysticat call.
1671-
expect(fetchStub.secondCall).to.be.null;
1670+
// Fail-closed happens after hasAccess but BEFORE the HEAD probe (which
1671+
// is fetch call 1) and the mysticat call (fetch call 2). Assert fetch
1672+
// was never invoked — cleaner than `secondCall.to.be.null` and order-
1673+
// independent of any future HEAD-probe placement changes.
1674+
expect(fetchStub).to.not.have.been.called;
16721675
});
16731676

16741677
it('returns 500 when Organization.findById throws (SITES-48037)', async () => {
@@ -1684,10 +1687,13 @@ describe('Preflight Controller', () => {
16841687
expect(result.message).to.equal('Failed to resolve site organization');
16851688
});
16861689

1687-
it('returns 500 when Organization.findById returns null (SITES-48037)', async () => {
1688-
// Site row exists but its Organization row was deleted / never existed.
1689-
// The optional-chain in the controller (`organization?.getImsOrgId()`)
1690-
// handles this; the missing-imsOrgId branch takes over.
1690+
it('returns 500 when Organization.findById returns null — dangling FK (SITES-48037)', async () => {
1691+
// Distinct from the empty-imsOrgId case: null org = data-integrity issue
1692+
// at the FK level (site.organization_id points to a row that doesn't
1693+
// exist — needs cleanup). Empty imsOrgId = config gap at the field
1694+
// level (needs org onboarding). Different remediation paths → different
1695+
// error messages, and 500 (not 400) because it's genuinely broken data
1696+
// in our tables that no client action can fix.
16911697
mockDataAccess.Organization.findById.resolves(null);
16921698
const response = await preflightController.createPreflight({
16931699
params: { siteId: 'test-site-123' },
@@ -1697,7 +1703,7 @@ describe('Preflight Controller', () => {
16971703
expect(response.status).to.equal(500);
16981704
const result = await response.json();
16991705
expect(result.errorCode).to.equal('PREFLIGHT_INTERNAL_ERROR');
1700-
expect(result.message).to.equal('Site organization is missing imsOrgId');
1706+
expect(result.message).to.equal('Site organization not found');
17011707
});
17021708

17031709
it('does not include x-page-auth header when HEAD returns 200 (no page-auth needed)', async () => {

0 commit comments

Comments
 (0)