Skip to content

Commit 5b5490b

Browse files
fix(organizations): replace full-doc save with $set sparse patch to eliminate race clobber (#3607)
* fix(organizations): replace full-doc save with \$set sparse patch to eliminate race clobber (#3605) OrganizationsRepository.update() used organization.save() — a full-document overwrite. Concurrent setPlan(orgId, 'pro') from a Stripe webhook could fire between the fetch and the save, silently reverting the plan field. Fix: - Rename repository method to updateById(orgId, fields) using findByIdAndUpdate with \$set + { new: true, runValidators: true, populate } - Service builds a sparse patch from the Zod-validated body, stripping billing-owned fields (plan, stripeCustomerId, stripeSubscriptionId) defensively so they can never flow through the settings endpoint - Add race-condition unit tests (6 tests): repository contract + service invariant that concurrent setPlan write is never overwritten Closes #3605 * refactor(organizations): drop dead billing-strip loop + modernize findByIdAndUpdate option - Remove BILLING_FIELDS delete loop in update service: the patch is built via an explicit allow-list (name, description, domain, slug), so billing fields can never be present and the loop is unreachable. Codacy + CodeRabbit both flagged it as dead code triggering object-injection lint warnings. - Switch findByIdAndUpdate option from deprecated `new: true` to canonical `returnDocument: 'after'` for consistency with setPlan and Mongoose v6+ docs. - Update race-invariant test to assert allow-list construction (no plan key in patch + only allowed keys present) rather than per-field stripping. Addresses CodeRabbit nit + Codacy ACTION_REQUIRED on PR #3607.
1 parent 0685f7c commit 5b5490b

3 files changed

Lines changed: 245 additions & 15 deletions

File tree

modules/organizations/repositories/organizations.repository.js

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,20 @@ const get = (id) => {
4444
};
4545

4646
/**
47-
* @function update
48-
* @description Data access operation to update an existing organization in the database.
49-
* @param {Object} organization - The organization object containing the updated details.
50-
* @returns {Promise<Object>} The updated organization.
47+
* @function updateById
48+
* @description Atomically update an organization by ID using a sparse field patch ($set).
49+
* Replaces the old full-document `save()` path to eliminate the race window where a
50+
* concurrent `setPlan` write (Stripe webhook) could be silently overwritten.
51+
* @param {String} orgId - The organization ObjectId (string).
52+
* @param {Object} fields - Sparse patch object with only the fields to update.
53+
* @returns {Promise<Object|null>} The updated organization with defaultPopulate, or null.
5154
*/
52-
const update = (organization) => organization.save().then((doc) => doc.populate(defaultPopulate));
55+
const updateById = (orgId, fields) => {
56+
if (!mongoose.Types.ObjectId.isValid(orgId)) return Promise.resolve(null);
57+
return Organization.findByIdAndUpdate(orgId, { $set: fields }, { returnDocument: 'after', runValidators: true })
58+
.populate(defaultPopulate)
59+
.exec();
60+
};
5361

5462
/**
5563
* @function remove
@@ -103,7 +111,7 @@ export default {
103111
list,
104112
create,
105113
get,
106-
update,
114+
updateById,
107115
remove,
108116
deleteMany,
109117
findOne,

modules/organizations/services/organizations.crud.service.js

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -135,27 +135,36 @@ const get = async (id) => {
135135

136136
/**
137137
* @function update
138-
* @description Service to update an existing organization.
139-
* @param {Object} organization - The existing organization object.
140-
* @param {Object} body - The object containing updated organization details.
138+
* @description Service to update an existing organization using a sparse $set patch.
139+
* The patch is constructed via an explicit allow-list so billing-owned fields (plan)
140+
* are never included — they are written exclusively by the Stripe webhook path
141+
* (setPlan / billing crons) and must never flow through this settings endpoint.
142+
* @param {Object} organization - The existing organization Mongoose document (for slug validation).
143+
* @param {Object} body - Zod-validated request body (already parsed by model.isValid middleware).
141144
* @returns {Promise<Object>} A promise resolving to the updated organization.
142145
*/
143146
const update = async (organization, body) => {
144-
if (body.name !== undefined) organization.name = body.name;
145-
if (body.description !== undefined) organization.description = body.description;
147+
// Build sparse patch from validated body via explicit allow-list.
148+
// Billing-owned fields (plan) are intentionally absent — they are written exclusively
149+
// by the Stripe webhook path (setPlan / billing crons).
150+
const patch = {};
151+
if (body.name !== undefined) patch.name = body.name;
152+
if (body.description !== undefined) patch.description = body.description;
153+
if (body.domain !== undefined) patch.domain = normalizeDomain(body.domain);
154+
155+
// Slug: validate uniqueness before adding to patch.
146156
if (body.slug !== undefined) {
147157
if (body.slug !== organization.slug) {
148158
const existing = await OrganizationsRepository.findOne({ slug: body.slug });
149159
if (existing) {
150160
throw new AppError('An organization with this slug already exists.', { code: 'CONFLICT' });
151161
}
152162
}
153-
organization.slug = body.slug;
163+
patch.slug = body.slug;
154164
}
155-
if (body.domain !== undefined) organization.domain = normalizeDomain(body.domain);
156-
if (body.plan !== undefined) organization.plan = body.plan;
157165

158-
const result = await OrganizationsRepository.update(organization);
166+
const orgId = organization._id || organization.id;
167+
const result = await OrganizationsRepository.updateById(String(orgId), patch);
159168
return result;
160169
};
161170

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
/**
2+
* Module dependencies.
3+
*/
4+
import { jest, describe, test, beforeEach, afterEach, expect } from '@jest/globals';
5+
6+
/**
7+
* Unit tests for OrganizationsRepository.updateById and OrganizationsCrudService.update
8+
* (#3605 — race-condition fix: replace full-doc save with $set sparse patch)
9+
*
10+
* Race scenario validated:
11+
* 1. Fetch org doc (plan = 'free')
12+
* 2. Mid-flight: Stripe webhook calls setPlan(orgId, 'pro') → raw findByIdAndUpdate
13+
* 3. Service calls updateById with { name: 'New name' } patch
14+
* 4. Asserts: doc has BOTH plan='pro' AND name='New name' — no clobber
15+
*/
16+
17+
// ─── Repository unit tests ───────────────────────────────────────────────────
18+
19+
describe('OrganizationsRepository.updateById (#3605):', () => {
20+
let OrganizationsRepository;
21+
let mockModel;
22+
23+
const orgId = '507f1f77bcf86cd799439011';
24+
25+
beforeEach(async () => {
26+
jest.resetModules();
27+
28+
const mockPopulate = jest.fn().mockReturnThis();
29+
const mockExec = jest.fn();
30+
31+
mockModel = {
32+
findByIdAndUpdate: jest.fn().mockReturnValue({
33+
populate: mockPopulate,
34+
exec: mockExec,
35+
}),
36+
};
37+
// Store refs so individual tests can override exec
38+
mockModel._mockPopulate = mockPopulate;
39+
mockModel._mockExec = mockExec;
40+
41+
jest.unstable_mockModule('mongoose', () => ({
42+
default: {
43+
Types: { ObjectId: { isValid: (id) => /^[a-f\d]{24}$/i.test(id) } },
44+
model: jest.fn(() => mockModel),
45+
},
46+
}));
47+
48+
const mod = await import('../repositories/organizations.repository.js');
49+
OrganizationsRepository = mod.default;
50+
});
51+
52+
afterEach(() => {
53+
jest.restoreAllMocks();
54+
});
55+
56+
test('calls findByIdAndUpdate with $set and returns populated doc', async () => {
57+
const updatedOrg = { _id: orgId, name: 'New name', plan: 'pro' };
58+
mockModel._mockExec.mockResolvedValue(updatedOrg);
59+
60+
const result = await OrganizationsRepository.updateById(orgId, { name: 'New name' });
61+
62+
expect(mockModel.findByIdAndUpdate).toHaveBeenCalledWith(
63+
orgId,
64+
{ $set: { name: 'New name' } },
65+
expect.objectContaining({ returnDocument: 'after', runValidators: true }),
66+
);
67+
expect(result).toEqual(updatedOrg);
68+
});
69+
70+
test('returns null for invalid orgId without querying DB', async () => {
71+
const result = await OrganizationsRepository.updateById('not-valid', { name: 'X' });
72+
73+
expect(result).toBeNull();
74+
expect(mockModel.findByIdAndUpdate).not.toHaveBeenCalled();
75+
});
76+
77+
test('returns null for empty orgId without querying DB', async () => {
78+
const result = await OrganizationsRepository.updateById('', { name: 'X' });
79+
80+
expect(result).toBeNull();
81+
expect(mockModel.findByIdAndUpdate).not.toHaveBeenCalled();
82+
});
83+
84+
test('propagates DB errors to the caller', async () => {
85+
const dbErr = new Error('timeout');
86+
mockModel._mockExec.mockRejectedValue(dbErr);
87+
88+
await expect(OrganizationsRepository.updateById(orgId, { name: 'X' })).rejects.toThrow('timeout');
89+
});
90+
});
91+
92+
// ─── Service race-condition test ──────────────────────────────────────────────
93+
94+
describe('OrganizationsCrudService.update — race-condition invariant (#3605):', () => {
95+
const orgId = '507f1f77bcf86cd799439011';
96+
97+
// Simulated in-memory "DB state" tracking concurrent writes
98+
let dbState;
99+
100+
// Mock updateById: applies patch to dbState and returns merged doc
101+
const mockUpdateById = jest.fn().mockImplementation(async (_id, patch) => {
102+
// Simulate atomic $set: only patch fields are written, others survive
103+
dbState = { ...dbState, ...patch };
104+
return { ...dbState };
105+
});
106+
107+
// Mock setPlan (Stripe webhook path): raw findByIdAndUpdate on plan only
108+
const mockFindByIdAndUpdate = jest.fn().mockImplementation(async (_id, planPatch) => {
109+
dbState = { ...dbState, ...planPatch };
110+
return { ...dbState };
111+
});
112+
113+
beforeEach(async () => {
114+
jest.resetModules();
115+
116+
dbState = { _id: orgId, name: 'Old name', plan: 'free', slug: 'old-org', domain: '', description: '' };
117+
118+
jest.unstable_mockModule('../repositories/organizations.repository.js', () => ({
119+
default: {
120+
updateById: mockUpdateById,
121+
findOne: jest.fn().mockResolvedValue(null),
122+
},
123+
}));
124+
125+
jest.unstable_mockModule('../../users/services/users.service.js', () => ({
126+
default: { updateById: jest.fn() },
127+
}));
128+
129+
jest.unstable_mockModule('../repositories/organizations.membership.repository.js', () => ({
130+
default: {
131+
list: jest.fn().mockResolvedValue([]),
132+
create: jest.fn().mockResolvedValue({ _id: 'm1' }),
133+
deleteMany: jest.fn(),
134+
findOne: jest.fn().mockResolvedValue(null),
135+
},
136+
}));
137+
138+
jest.unstable_mockModule('../../../lib/helpers/AppError.js', () => ({
139+
default: class AppError extends Error {
140+
constructor(msg, opts) { super(msg); this.code = opts?.code; }
141+
},
142+
}));
143+
144+
jest.unstable_mockModule('../../../lib/helpers/emailVerification.js', () => ({
145+
assertEmailVerified: jest.fn(),
146+
}));
147+
148+
jest.unstable_mockModule('../helpers/organizations.slug.js', () => ({
149+
slugify: jest.fn().mockReturnValue('old-org'),
150+
}));
151+
152+
jest.unstable_mockModule('../../../lib/services/logger.js', () => ({
153+
default: { error: jest.fn(), warn: jest.fn(), info: jest.fn() },
154+
}));
155+
156+
jest.unstable_mockModule('../../../config/index.js', () => ({
157+
default: { organizations: { publicDomains: [] } },
158+
}));
159+
});
160+
161+
afterEach(() => {
162+
jest.restoreAllMocks();
163+
mockUpdateById.mockClear();
164+
mockFindByIdAndUpdate.mockClear();
165+
});
166+
167+
test(
168+
'concurrent setPlan write is preserved — $set patch never overwrites plan (#3605 race fix)',
169+
async () => {
170+
const { default: OrgCrudService } = await import('../services/organizations.crud.service.js');
171+
172+
// Org doc as fetched before the webhook fires (plan = 'free')
173+
const organization = { _id: orgId, id: orgId, name: 'Old name', plan: 'free', slug: 'old-org', domain: '' };
174+
175+
// Mid-flight: Stripe webhook sets plan = 'pro' directly in the DB
176+
await mockFindByIdAndUpdate(orgId, { plan: 'pro' });
177+
// DB now: { name: 'Old name', plan: 'pro' }
178+
179+
// Service update: only name patch, plan field absent from body (billing-owned)
180+
const result = await OrgCrudService.update(organization, { name: 'New name' });
181+
182+
// updateById must have been called with a patch that does NOT contain 'plan'
183+
const [, patch] = mockUpdateById.mock.calls[0];
184+
expect(patch).not.toHaveProperty('plan');
185+
186+
// The returned doc must have BOTH the webhook's plan AND the service's name change
187+
expect(result.name).toBe('New name');
188+
expect(result.plan).toBe('pro'); // webhook write survives — no clobber
189+
},
190+
);
191+
192+
test(
193+
'plan is excluded from patch by allow-list construction — billing fields never flow through update (#3605)',
194+
async () => {
195+
const { default: OrgCrudService } = await import('../services/organizations.crud.service.js');
196+
197+
const organization = { _id: orgId, id: orgId, name: 'Old name', plan: 'free', slug: 'old-org', domain: '' };
198+
199+
// Even when body contains plan, the allow-list in update() only copies known
200+
// settable fields (name, description, domain, slug) — plan is structurally absent.
201+
await OrgCrudService.update(organization, { name: 'New name', plan: 'enterprise' });
202+
203+
const [, patch] = mockUpdateById.mock.calls[0];
204+
// plan must not appear in the patch — it is excluded by allow-list construction
205+
expect(patch).not.toHaveProperty('plan');
206+
// Only allow-listed fields may be present
207+
const allowedKeys = new Set(['name', 'description', 'domain', 'slug']);
208+
for (const key of Object.keys(patch)) {
209+
expect(allowedKeys.has(key)).toBe(true);
210+
}
211+
},
212+
);
213+
});

0 commit comments

Comments
 (0)