-
Notifications
You must be signed in to change notification settings - Fork 15
fix(brands): reject anchoring a brand to another org's site #3096
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
Changes from all commits
f25363e
4538d50
f4e0b03
c1765e6
dcf8710
2825449
050af09
a716090
3b91b7c
137cf01
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 |
|---|---|---|
|
|
@@ -336,6 +336,57 @@ async function replaceChildRows(table, brandId, rows, onConflict, postgrestClien | |
| } | ||
| } | ||
|
|
||
| /** | ||
| * Verifies a candidate primary site (`baseSiteId`) belongs to the same org as the | ||
| * brand being anchored to it, before that site_id is ever persisted. | ||
| * | ||
| * serenity-docs#346: `brand.organization_id != site.organization_id` is exactly the | ||
| * org-ID mismatch pattern the investigation traced (Tata Capital, BMW, Toyota, ...) — | ||
| * a brand silently anchored to a *different* org's site. Both upsertBrand (fresh | ||
| * create / first anchor) and updateBrand (first set, or pending re-point) must call | ||
| * this before writing site_id; the immutable-once-set branches in each are | ||
| * unaffected, since they already refuse to change an existing active site_id. | ||
| * | ||
| * @param {object} postgrestClient - PostgREST client | ||
| * @param {string} siteId - Candidate `brands.site_id` | ||
| * @param {string} organizationId - SpaceCat organization UUID the brand belongs to | ||
| * @param {string} brandLabel - Whatever identifies the brand in the caller's context, | ||
| * for the error message only — upsertBrand passes the brand name (not yet | ||
| * persisted, so no id exists yet); updateBrand passes the fetched brand's | ||
| * name when its existing-row read found one, else falls back to brandId. | ||
|
Collaborator
Author
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. Fixed in 2825449: updated the brandLabel JSDoc to say updateBrand passes the fetched brand's name when its existing-row read found one, falling back to brandId — matching the existing?.name || brandId change from the last round. |
||
| * @throws {Error} status 409, code 'brand_site_org_mismatch', if the site does not | ||
| * belong to organizationId (including if it doesn't exist at all) | ||
| */ | ||
| async function assertSiteBelongsToOrg(postgrestClient, siteId, organizationId, brandLabel) { | ||
| const { data: anchorSite, error: anchorSiteError } = await postgrestClient | ||
| .from('sites') | ||
| .select('id') | ||
| .eq('id', siteId) | ||
| .eq('organization_id', organizationId) | ||
| .maybeSingle(); | ||
| if (anchorSiteError) { | ||
|
Collaborator
Author
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. Added in 2825449 (test/support/brands-storage.test.js): 'fails closed with an untyped error when the sites org-membership lookup errors' — seeds a PostgREST error on the sites lookup, asserts the exact thrown message ('Failed to verify primary site org for brand "Test": connection reset') and that err.status/err.code are both undefined, so a DB-read failure surfaces as a plain 500 rather than the typed 409. |
||
| throw new Error( | ||
| `Failed to verify primary site org for brand "${brandLabel}": ${anchorSiteError.message}`, | ||
| ); | ||
| } | ||
| if (!anchorSite) { | ||
| // Plain ASCII (no em dash) to match this file's other thrown, client-facing | ||
| // messages: an em dash here previously crashed createErrorResponse's | ||
| // X-Error header (@adobe/fetch rejects non-Latin1 header content with a | ||
| // raw TypeError, surfacing as a 500 instead of this 409 — caught by the | ||
| // it-postgres IT suite, not the mocked unit tests). createErrorResponse | ||
| // now sanitizes the header regardless (serenity-docs#346), but this stays | ||
| // ASCII too rather than leaning on that alone. | ||
| const err = new Error( | ||
| `Cannot anchor brand "${brandLabel}" to site ${siteId}: that site ` | ||
| + `does not exist, or does not belong to organization ${organizationId}.`, | ||
| ); | ||
|
Collaborator
Author
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. Fixed in 2825449: the message now reads "that site does not exist, or does not belong to organization ${organizationId}." — covers the stale/typo'd-UUID case explicitly instead of implying the site was found but simply owned by someone else. |
||
| err.status = 409; | ||
| err.code = 'brand_site_org_mismatch'; | ||
| throw err; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Fully replaces brand_sites for a brand. Groups submitted URLs by normalized base URL | ||
| * (via composeBaseURL) so that multiple paths under the same site share one brand_sites row. | ||
|
|
@@ -1200,6 +1251,18 @@ export async function upsertBrand({ | |
| // (which left Semrush brands' site_id NULL) is removed; a genuine collision with | ||
| // another brand's primary site still surfaces as the brands_base_site_unique 409 | ||
| // handled below. | ||
| // serenity-docs#346: a brand's primary site must belong to the same org as the | ||
| // brand itself — anchoring to another org's site is exactly the org-ID mismatch | ||
| // pattern the investigation traced (Tata Capital, BMW, Toyota, ...). Verify on | ||
| // both paths that assign a *new* anchor (fresh create, or first anchor for a | ||
| // previously Semrush-only brand); the immutable-once-set branch below is | ||
| // unaffected since it already refuses to change an existing site_id. | ||
| const wantsNewAnchor = hasText(brand.baseSiteId) | ||
| && (existing === null || !hasText(existing.site_id)); | ||
| if (wantsNewAnchor) { | ||
| await assertSiteBelongsToOrg(postgrestClient, brand.baseSiteId, organizationId, brand.name); | ||
| } | ||
|
|
||
| if (existing === null) { | ||
| row.site_id = hasText(brand.baseSiteId) ? brand.baseSiteId : null; | ||
| } else if (hasText(brand.baseSiteId) && !hasText(existing.site_id)) { | ||
|
|
@@ -1312,7 +1375,7 @@ export async function updateBrand({ | |
| if (needsExistingFetch) { | ||
| const { data: current, error: currentError } = await postgrestClient | ||
| .from('brands') | ||
| .select('site_id, status, updated_at') | ||
| .select('name, site_id, status, updated_at') | ||
| .eq('id', brandId) | ||
| .maybeSingle(); | ||
| // Fail closed: a swallowed read error leaves `current` null, so the guard | ||
|
|
@@ -1379,6 +1442,10 @@ export async function updateBrand({ | |
| patch.site_id = null; | ||
| } | ||
| } else if (hasText(updates.baseSiteId) && (!existing?.site_id || isPending)) { | ||
| // serenity-docs#346: same org-ID mismatch guard as upsertBrand — verify the | ||
| // new/re-pointed site actually belongs to this brand's org before persisting. | ||
| const brandLabel = existing?.name || brandId; | ||
| await assertSiteBelongsToOrg(postgrestClient, updates.baseSiteId, organizationId, brandLabel); | ||
| patch.site_id = updates.baseSiteId; | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,6 +16,7 @@ import { | |
| BRAND_1_ID, | ||
| SITE_2_ID, | ||
| SITE_2_BASE_URL, | ||
| SITE_3_ID, // belongs to ORG_2, not ORG_1 (seed-data/sites.js) — used for cross-org tests | ||
|
Collaborator
Author
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. Fixed in 2825449: added an inline comment at the SITE_3_ID import ('belongs to ORG_2, not ORG_1 (seed-data/sites.js) — used for cross-org tests') so a reader doesn't have to jump into the test body below to learn why it's the right fixture for the cross-org rejection tests. |
||
| MARKET_SITE_1_ID, | ||
| MARKET_SITE_1_BASE_URL, | ||
| } from '../seed-ids.js'; | ||
|
|
@@ -237,6 +238,50 @@ export default function brandsTests(getHttpClient, resetData) { | |
| }); | ||
| }); | ||
|
|
||
| describe('Brands v2 rejects a cross-org baseSiteId (serenity-docs#346)', () => { | ||
| before(() => resetData()); | ||
|
|
||
| it('rejects a fresh create anchored to another org\'s site', async () => { | ||
| const http = getHttpClient(); | ||
|
|
||
| // SITE_3_ID belongs to ORG_2 (seed-data/sites.js) — anchoring an ORG_1 | ||
| // brand to it is exactly the org-ID mismatch signature the investigation | ||
| // traced (a brand silently pointing at a different org's site). | ||
| // | ||
| // status: 'pending' defers ALL Semrush provisioning (see createBrandForOrg), | ||
| // so this stays a plain flat-mode create regardless of ORG_1's serenity | ||
| // rollout flag — the guard fires on the baseSiteId anchor check itself, | ||
| // independent of that unrelated machinery. | ||
| const res = await http.admin.post(`/v2/orgs/${ORG_1_ID}/brands`, { | ||
| name: 'Cross-Org Anchor Attempt', region: ['US'], status: 'pending', baseSiteId: SITE_3_ID, | ||
| }); | ||
|
|
||
| expect(res.status).to.equal(409); | ||
| expect(res.body.code).to.equal('brand_site_org_mismatch'); | ||
| }); | ||
|
|
||
| it('rejects setting an existing pending brand\'s baseSiteId to another org\'s site', async () => { | ||
| const http = getHttpClient(); | ||
|
|
||
| const create = await http.admin.post(`/v2/orgs/${ORG_1_ID}/brands`, { | ||
| name: 'Pending Brand For Cross-Org Attempt', region: ['US'], status: 'pending', | ||
| }); | ||
| expect(create.status).to.equal(201); | ||
| const { id: brandId } = create.body; | ||
|
|
||
| const res = await http.admin.patch(`/v2/orgs/${ORG_1_ID}/brands/${brandId}`, { | ||
| baseSiteId: SITE_3_ID, | ||
| }); | ||
|
|
||
| expect(res.status).to.equal(409); | ||
| expect(res.body.code).to.equal('brand_site_org_mismatch'); | ||
|
|
||
| // The brand must be untouched — no partial write. | ||
| const getRes = await http.admin.get(`/v2/orgs/${ORG_1_ID}/brands/${brandId}`); | ||
| expect(getRes.body.baseSiteId == null).to.equal(true); | ||
| }); | ||
| }); | ||
|
|
||
| describe('Brands v2 delete frees the name for reuse (LLMO-6978)', () => { | ||
| before(() => resetData()); | ||
|
|
||
|
|
||
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.
Fixed in 137cf01: the 500 fallback now builds its response directly via createResponse (like the typed-status branch above it), sanitizing only the X-Error header copy and keeping the JSON body's message raw. Dropped the now-unused internalServerError import. Added a test proving the body stays intact while the header gets stripped.