From 52c6dbe277de2024c981e5585a34917db85acf96 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Sat, 13 Jun 2026 17:24:04 +0200 Subject: [PATCH 1/6] feat(organizations): decline service, last-owner scope, pending list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit declineMembership mirrors acceptMembership's consent gate (pending + owner_add + caller is the invitee) and deletes the row so the user can be re-invited. remove() now runs last-owner protection only for ACTIVE owner rows — cancelling a pending owner-role invite in a 1-active-owner org no longer throws. list() surfaces pending owner_add rows next to active members so the inviting owner can see and cancel an invite; pending join_requests keep their own approval surface. refs #3831 --- .../organizations.membership.service.js | 51 +++- ...zations.membership.lifecycle.unit.tests.js | 232 ++++++++++++++++++ 2 files changed, 280 insertions(+), 3 deletions(-) create mode 100644 modules/organizations/tests/organizations.membership.lifecycle.unit.tests.js diff --git a/modules/organizations/services/organizations.membership.service.js b/modules/organizations/services/organizations.membership.service.js index 03d4f47b8..07bc0174b 100644 --- a/modules/organizations/services/organizations.membership.service.js +++ b/modules/organizations/services/organizations.membership.service.js @@ -55,7 +55,12 @@ const validateLastOwnerProtection = async (organizationId) => { /** * @function list - * @description Service to retrieve active memberships for an organization. + * @description Service to retrieve an organization's members surface: ACTIVE + * memberships PLUS pending owner_add invitations — so the inviting owner/admin + * can SEE (and cancel via DELETE /members/:memberId) an invite they created. + * Pending join_requests stay OUT: they have their own approval surface + * (listPending) and must never look like members. Rows carry status + source + * so callers can render the pending state. * @param {String} organizationId - The ID of the organization. * @param {String} [search] - Optional search string to filter by user name/email. * @param {Number} [page] - Optional page number for pagination. @@ -63,7 +68,13 @@ const validateLastOwnerProtection = async (organizationId) => { * @returns {Promise} A promise that resolves to the list of memberships. */ const list = async (organizationId, search, page, perPage) => { - const filter = { organizationId, status: MEMBERSHIP_STATUSES.ACTIVE }; + const filter = { + organizationId, + $or: [ + { status: MEMBERSHIP_STATUSES.ACTIVE }, + { status: MEMBERSHIP_STATUSES.PENDING, source: PENDING_SOURCES.OWNER_ADD }, + ], + }; if (search) { const matchingUsers = await UserService.searchByNameOrEmail(search); filter.userId = { $in: matchingUsers.map((u) => u._id) }; @@ -128,7 +139,10 @@ const updateRole = async (membership, role) => { * @returns {Promise} A promise resolving to a confirmation of the deletion. */ const remove = async (membership) => { - if (membership.role === MEMBERSHIP_ROLES.OWNER) { + // Last-owner protection applies ONLY to ACTIVE owner rows: cancelling a PENDING + // owner-role invite never reduces the active-owner count, so it must not be + // blocked when the org has a single active owner (the inviter) — #3831. + if (membership.role === MEMBERSHIP_ROLES.OWNER && membership.status === MEMBERSHIP_STATUSES.ACTIVE) { const orgId = membership.organizationId._id || membership.organizationId; await validateLastOwnerProtection(orgId); } @@ -430,6 +444,36 @@ const acceptMembership = async (membershipId, acceptingUserId) => { return result; }; +/** + * @function declineMembership + * @description The INVITED USER declines a pending owner_add membership, deleting + * the row. CONSENT-CRITICAL — the gate is IDENTICAL to acceptMembership: the + * membership must be PENDING + source 'owner_add' AND belong to the + * authenticated caller; any mismatch (wrong user, a join_request, an + * already-active or unknown membership) returns null so the controller can + * answer 404 without leaking which condition failed. Deleting (not flagging) + * mirrors rejectRequest — the user can be re-invited later. This makes the + * createJoinRequest copy ('Please accept or decline it') honest. + * @param {String} membershipId - The pending owner_add membership to decline. + * @param {String} decliningUserId - The authenticated user declining (must be the invitee). + * @returns {Promise} The deleted membership row, or null if not declinable by this user. + */ +const declineMembership = async (membershipId, decliningUserId) => { + // Self-defending consent gate, same rationale as acceptMembership: reject an + // absent caller up-front so the String(undefined) compare can never collide. + if (!decliningUserId) return null; + const membership = await MembershipRepository.get(membershipId); + if (!membership) return null; + + const membershipUserId = String(membership.userId?._id || membership.userId); + const isOwnerAddPending = membership.status === MEMBERSHIP_STATUSES.PENDING + && membership.source === PENDING_SOURCES.OWNER_ADD; + if (!isOwnerAddPending || membershipUserId !== String(decliningUserId)) return null; + + await MembershipRepository.remove(membership); + return membership; +}; + /** * @function leave * @description Leave an organization. Prevents the last owner from leaving. @@ -505,6 +549,7 @@ export default { findUserByExactEmail, addMember, acceptMembership, + declineMembership, count, aggregateCountByOrganizations, deleteMany, diff --git a/modules/organizations/tests/organizations.membership.lifecycle.unit.tests.js b/modules/organizations/tests/organizations.membership.lifecycle.unit.tests.js new file mode 100644 index 000000000..3256f83c8 --- /dev/null +++ b/modules/organizations/tests/organizations.membership.lifecycle.unit.tests.js @@ -0,0 +1,232 @@ +/** + * Module dependencies. + */ +import { jest, describe, test, expect, beforeEach } from '@jest/globals'; + +import { MEMBERSHIP_STATUSES, MEMBERSHIP_ROLES, PENDING_SOURCES } from '../lib/constants.js'; + +/** + * Lifecycle unit tests for the pending owner_add membership (#3831). + * + * Covers the three service-level fixes: + * 1. declineMembership — the invitee-side delete path, consent gate IDENTICAL + * to acceptMembership (pending + source owner_add + caller is the invitee); + * 2. remove() last-owner scope — the protection only applies to ACTIVE owner + * rows, so cancelling a PENDING owner-role invite never spuriously throws; + * 3. list() pending visibility — active members + pending owner_add invites, + * pending join_requests stay on their own approval surface (listPending). + */ + +const mockFindOne = jest.fn(); +const mockCreate = jest.fn(); +const mockUpdate = jest.fn(); +const mockGet = jest.fn(); +const mockList = jest.fn(); +const mockCount = jest.fn(); +const mockRemove = jest.fn(); +jest.unstable_mockModule('../repositories/organizations.membership.repository.js', () => ({ + default: { + findOne: mockFindOne, + create: mockCreate, + update: mockUpdate, + get: mockGet, + list: mockList, + count: mockCount, + remove: mockRemove, + }, +})); + +const mockOrgGet = jest.fn(); +jest.unstable_mockModule('../repositories/organizations.repository.js', () => ({ + default: { get: mockOrgGet }, +})); + +const mockGetBrut = jest.fn(); +const mockUpdateById = jest.fn().mockResolvedValue({}); +const mockSearchByNameOrEmail = jest.fn(); +jest.unstable_mockModule('../../users/services/users.service.js', () => ({ + default: { getBrut: mockGetBrut, updateById: mockUpdateById, searchByNameOrEmail: mockSearchByNameOrEmail }, +})); + +jest.unstable_mockModule('../../../lib/helpers/mailer/index.js', () => ({ + default: { isConfigured: jest.fn().mockReturnValue(false), sendMail: jest.fn() }, +})); +jest.unstable_mockModule('../../../lib/helpers/getBaseUrl.js', () => ({ + default: jest.fn().mockReturnValue('http://localhost:3000'), +})); +jest.unstable_mockModule('../../../config/index.js', () => ({ + default: { app: { title: 'Test' } }, +})); +jest.unstable_mockModule('../../../lib/helpers/emailVerification.js', () => ({ + assertEmailVerified: jest.fn(), +})); +jest.unstable_mockModule('../../../lib/services/logger.js', () => ({ + default: { warn: jest.fn(), error: jest.fn(), info: jest.fn() }, +})); + +const { default: MembershipService } = await import('../services/organizations.membership.service.js'); + +const ORG = 'org1'; +const USER = 'user1'; +const OWNER = 'owner1'; + +describe('Membership owner_add lifecycle unit tests:', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetBrut.mockResolvedValue({ _id: USER, currentOrganization: 'other-org' }); + mockRemove.mockResolvedValue({ deletedCount: 1 }); + }); + + describe('declineMembership — consent gate (mirror of acceptMembership)', () => { + /** + * @desc Build a pending owner_add membership owned by USER. + * @param {Object} overrides - field overrides + * @returns {Object} membership-like doc + */ + const ownerAddPending = (overrides = {}) => ({ + _id: 'm1', + userId: USER, + organizationId: ORG, + status: MEMBERSHIP_STATUSES.PENDING, + source: PENDING_SOURCES.OWNER_ADD, + ...overrides, + }); + + test('the INVITED USER can decline their own pending owner_add → row deleted and returned', async () => { + const membership = ownerAddPending(); + mockGet.mockResolvedValue(membership); + + const result = await MembershipService.declineMembership('m1', USER); + + expect(result).toBe(membership); + expect(mockRemove).toHaveBeenCalledTimes(1); + expect(mockRemove).toHaveBeenCalledWith(membership); + }); + + test('a DIFFERENT user (e.g. the inviting owner) cannot decline → null, no deletion', async () => { + mockGet.mockResolvedValue(ownerAddPending()); + + const result = await MembershipService.declineMembership('m1', OWNER); + + expect(result).toBeNull(); + expect(mockRemove).not.toHaveBeenCalled(); + }); + + test('a JOIN REQUEST row cannot be declined here (owner reject path owns it) → null', async () => { + mockGet.mockResolvedValue(ownerAddPending({ source: PENDING_SOURCES.JOIN_REQUEST })); + + const result = await MembershipService.declineMembership('m1', USER); + + expect(result).toBeNull(); + expect(mockRemove).not.toHaveBeenCalled(); + }); + + test('an ACTIVE membership cannot be declined (leave/remove own that path) → null', async () => { + mockGet.mockResolvedValue(ownerAddPending({ status: MEMBERSHIP_STATUSES.ACTIVE })); + + const result = await MembershipService.declineMembership('m1', USER); + + expect(result).toBeNull(); + expect(mockRemove).not.toHaveBeenCalled(); + }); + + test('an unknown membership id → null', async () => { + mockGet.mockResolvedValue(null); + const result = await MembershipService.declineMembership('missing', USER); + expect(result).toBeNull(); + expect(mockRemove).not.toHaveBeenCalled(); + }); + + test('SELF-DEFENDING GATE: an undefined decliningUserId → null, no deletion', async () => { + mockGet.mockResolvedValue(ownerAddPending()); + const result = await MembershipService.declineMembership('m1', undefined); + expect(result).toBeNull(); + expect(mockRemove).not.toHaveBeenCalled(); + }); + + test('matches the invitee even when userId is a populated sub-doc', async () => { + const membership = ownerAddPending({ userId: { _id: USER, email: 'a@b.com' } }); + mockGet.mockResolvedValue(membership); + + const result = await MembershipService.declineMembership('m1', USER); + expect(result).toBe(membership); + expect(mockRemove).toHaveBeenCalledWith(membership); + }); + }); + + describe('remove() — last-owner protection scoped to ACTIVE owner rows', () => { + test('cancelling a PENDING owner-role invite in a 1-active-owner org succeeds (guard skipped)', async () => { + const invite = { + _id: 'm2', + userId: USER, + organizationId: ORG, + role: MEMBERSHIP_ROLES.OWNER, + status: MEMBERSHIP_STATUSES.PENDING, + source: PENDING_SOURCES.OWNER_ADD, + }; + mockCount.mockResolvedValue(1); // would make the guard throw if it ran + mockList.mockResolvedValue([]); + + await expect(MembershipService.remove(invite)).resolves.toEqual({ success: true }); + expect(mockCount).not.toHaveBeenCalled(); + expect(mockRemove).toHaveBeenCalledWith(invite); + }); + + test('removing the LAST ACTIVE owner still throws', async () => { + const lastOwner = { + _id: 'm3', + userId: USER, + organizationId: ORG, + role: MEMBERSHIP_ROLES.OWNER, + status: MEMBERSHIP_STATUSES.ACTIVE, + }; + mockCount.mockResolvedValue(1); + + await expect(MembershipService.remove(lastOwner)).rejects.toThrow('at least one active owner'); + expect(mockRemove).not.toHaveBeenCalled(); + }); + + test('removing an ACTIVE owner among several still succeeds (guard runs and passes)', async () => { + const owner = { + _id: 'm4', + userId: USER, + organizationId: ORG, + role: MEMBERSHIP_ROLES.OWNER, + status: MEMBERSHIP_STATUSES.ACTIVE, + }; + mockCount.mockResolvedValue(2); + mockList.mockResolvedValue([]); + + await expect(MembershipService.remove(owner)).resolves.toEqual({ success: true }); + expect(mockCount).toHaveBeenCalledTimes(1); + expect(mockRemove).toHaveBeenCalledWith(owner); + }); + }); + + describe('list() — pending owner_add visibility for the inviting owner/admin', () => { + test('filters to ACTIVE rows OR pending owner_add rows (pending join_requests stay out)', async () => { + mockList.mockResolvedValue([]); + + await MembershipService.list(ORG); + + const filter = mockList.mock.calls[0][0]; + expect(filter.organizationId).toBe(ORG); + expect(filter.status).toBeUndefined(); + expect(filter.$or).toEqual([ + { status: MEMBERSHIP_STATUSES.ACTIVE }, + { status: MEMBERSHIP_STATUSES.PENDING, source: PENDING_SOURCES.OWNER_ADD }, + ]); + }); + + test('search still narrows by matched userIds on top of the $or filter', async () => { + mockList.mockResolvedValue([]); + mockSearchByNameOrEmail.mockResolvedValue([{ _id: 'u7' }]); + + await MembershipService.list(ORG, 'ada'); + + const filter = mockList.mock.calls[0][0]; + expect(filter.userId).toEqual({ $in: ['u7'] }); + expect(filter.$or).toBeDefined(); + }); + }); +}); From 732babf2e19f4a8a66e2700e5ce4d697ff0afc20 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Sat, 13 Jun 2026 17:24:49 +0200 Subject: [PATCH 2/6] fix(users): sweep pending memberships on user delete remove() iterates listByUser, which is ACTIVE-only, so a deleted user's PENDING rows (join_request and owner_add) survived as orphans pointing at a dead userId. Sweep them explicitly after the active-membership processing. refs #3831 --- modules/users/services/users.service.js | 6 + ....service.remove.pendingSweep.unit.tests.js | 122 ++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 modules/users/tests/users.service.remove.pendingSweep.unit.tests.js diff --git a/modules/users/services/users.service.js b/modules/users/services/users.service.js index 50d758e2d..e388302cd 100644 --- a/modules/users/services/users.service.js +++ b/modules/users/services/users.service.js @@ -197,6 +197,12 @@ const remove = async (user) => { await MembershipService.deleteMany({ _id: membership._id }); } + // Sweep the user's PENDING rows (both join_request and owner_add). The cleanup + // loop above iterates listByUser, which is ACTIVE-only — without this sweep a + // deleted user's pending invitations / join requests would survive as orphans + // pointing at a dead userId (and keep occupying the (user, org) unique slot). + await MembershipRepository.deleteMany({ userId, status: MEMBERSHIP_STATUSES.PENDING }); + const result = await UserRepository.remove(user); return result; }; diff --git a/modules/users/tests/users.service.remove.pendingSweep.unit.tests.js b/modules/users/tests/users.service.remove.pendingSweep.unit.tests.js new file mode 100644 index 000000000..b78c1cb89 --- /dev/null +++ b/modules/users/tests/users.service.remove.pendingSweep.unit.tests.js @@ -0,0 +1,122 @@ +/** + * Unit tests — users.service.remove must sweep the deleted user's PENDING + * membership rows (both join_request and owner_add): the cleanup loop iterates + * listByUser, which is ACTIVE-only, so without the sweep pending rows survive + * as orphans pointing at a dead userId. Issue #3831. + */ +import { jest, describe, test, expect, beforeEach } from '@jest/globals'; + +const mockMembershipServiceListByUser = jest.fn(); +const mockMembershipServiceCount = jest.fn(); +const mockMembershipServiceDeleteMany = jest.fn(); +const mockMembershipRepositoryList = jest.fn(); +const mockMembershipRepositoryDeleteMany = jest.fn(); +const mockUserRepositoryFindWithFilter = jest.fn(); +const mockUserRepositoryUpdateById = jest.fn(); +const mockUserRepositoryRemove = jest.fn(); +const mockOrgRepositoryRemove = jest.fn(); + +jest.unstable_mockModule('../../../lib/services/logger.js', () => ({ + default: { error: jest.fn(), warn: jest.fn(), info: jest.fn() }, +})); + +jest.unstable_mockModule('../repositories/users.repository.js', () => ({ + default: { + list: jest.fn(), + create: jest.fn(), + get: jest.fn(), + update: jest.fn(), + remove: mockUserRepositoryRemove, + stats: jest.fn(), + updateById: mockUserRepositoryUpdateById, + findWithFilter: mockUserRepositoryFindWithFilter, + findByIdAndUpdatePopulated: jest.fn(), + searchByNameOrEmail: jest.fn(), + search: jest.fn(), + }, +})); + +jest.unstable_mockModule('../../organizations/services/organizations.membership.service.js', () => ({ + default: { + listByUser: mockMembershipServiceListByUser, + count: mockMembershipServiceCount, + deleteMany: mockMembershipServiceDeleteMany, + }, +})); + +jest.unstable_mockModule('../../organizations/repositories/organizations.repository.js', () => ({ + default: { + remove: mockOrgRepositoryRemove, + findOne: jest.fn().mockResolvedValue(null), + list: jest.fn().mockResolvedValue([]), + create: jest.fn(), + update: jest.fn(), + get: jest.fn(), + exists: jest.fn().mockResolvedValue(false), + updateById: jest.fn(), + }, +})); + +jest.unstable_mockModule('../../organizations/repositories/organizations.membership.repository.js', () => ({ + default: { + list: mockMembershipRepositoryList, + findOne: jest.fn(), + create: jest.fn(), + deleteMany: mockMembershipRepositoryDeleteMany, + update: jest.fn(), + remove: jest.fn(), + count: jest.fn(), + }, +})); + +jest.unstable_mockModule('../../auth/services/auth.service.js', () => ({ + default: { signOut: jest.fn() }, +})); + +jest.unstable_mockModule('../../../config/index.js', () => ({ + default: { organizations: { enabled: true } }, +})); + +jest.unstable_mockModule('../utils/sanitizeUser.js', () => ({ + removeSensitive: jest.fn((u) => u), +})); + +jest.unstable_mockModule('lodash', () => ({ + default: { pick: jest.fn((o) => o) }, +})); + +const { default: UsersService } = await import('../services/users.service.js'); + +describe('users.service.remove — pending membership sweep (#3831):', () => { + const user = { _id: 'uid1', id: 'uid1' }; + + beforeEach(() => { + jest.clearAllMocks(); + mockUserRepositoryRemove.mockResolvedValue({ deletedCount: 1 }); + mockMembershipServiceDeleteMany.mockResolvedValue({}); + mockMembershipRepositoryDeleteMany.mockResolvedValue({ deletedCount: 0 }); + }); + + test('sweeps the user PENDING rows (both sources) even with no active membership', async () => { + mockMembershipServiceListByUser.mockResolvedValue([]); + + await UsersService.remove(user); + + expect(mockMembershipRepositoryDeleteMany).toHaveBeenCalledWith({ userId: 'uid1', status: 'pending' }); + expect(mockUserRepositoryRemove).toHaveBeenCalledTimes(1); + }); + + test('sweeps PENDING rows after processing active memberships (non-owner path)', async () => { + mockMembershipServiceListByUser.mockResolvedValue([ + { _id: 'm1', role: 'member', organizationId: { _id: 'orgX' }, status: 'active' }, + ]); + + await UsersService.remove(user); + + // Active membership handled via the existing per-row delete... + expect(mockMembershipServiceDeleteMany).toHaveBeenCalledWith({ _id: 'm1' }); + // ...and the pending sweep ran exactly once with the userId + pending filter. + expect(mockMembershipRepositoryDeleteMany).toHaveBeenCalledTimes(1); + expect(mockMembershipRepositoryDeleteMany).toHaveBeenCalledWith({ userId: 'uid1', status: 'pending' }); + }); +}); From adc37c8491c7846f9fa9cc2560a7594500f2ecb2 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Sat, 13 Jun 2026 17:26:06 +0200 Subject: [PATCH 3/6] feat(organizations): invitee decline route for owner_add invites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DELETE /api/membership-requests/:membershipId — auth-only chain like the accept route; the consent gate lives in the service and answers 404 on any mismatch without leaking which condition failed. Registered after /mine and /mine/pending so those literals are never captured as an id. Makes the 'Please accept or decline it' join-request copy honest, proven end-to-end by the new e2e flow. refs #3831 --- ...anizations.membershipRequest.controller.js | 27 +++ .../organizations.membershipRequest.routes.js | 11 ++ .../tests/organizations.decline.e2e.tests.js | 175 ++++++++++++++++++ 3 files changed, 213 insertions(+) create mode 100644 modules/organizations/tests/organizations.decline.e2e.tests.js diff --git a/modules/organizations/controllers/organizations.membershipRequest.controller.js b/modules/organizations/controllers/organizations.membershipRequest.controller.js index d53a9b0db..bd5f35486 100644 --- a/modules/organizations/controllers/organizations.membershipRequest.controller.js +++ b/modules/organizations/controllers/organizations.membershipRequest.controller.js @@ -143,6 +143,32 @@ const accept = async (req, res) => { } }; +/** + * @function decline + * @description Endpoint for the INVITED USER to decline a pending owner_add + * membership — deletes the row (the user can be re-invited later). Same + * auth-only surface and consent gate as accept: the service returns null on any + * mismatch (wrong user, a join_request, an already-active or unknown + * membership) → 404, never leaking which condition failed. + * @param {Object} req - Express request object + * @param {Object} res - Express response object + * @returns {Promise} + */ +const decline = async (req, res) => { + try { + const membership = await MembershipService.declineMembership( + req.params.membershipId, + req.user._id || req.user.id, + ); + if (!membership) { + return responses.error(res, 404, 'Not Found', 'No pending invitation with that identifier has been found')(); + } + responses.success(res, 'membership invitation declined')(membership); + } catch (err) { + responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err); + } +}; + /** * @function requestByID * @description Middleware to fetch a pending membership by its ID. @@ -180,5 +206,6 @@ export default { listMine, listMinePending, accept, + decline, requestByID, }; diff --git a/modules/organizations/routes/organizations.membershipRequest.routes.js b/modules/organizations/routes/organizations.membershipRequest.routes.js index 07450eab0..908ef54f8 100644 --- a/modules/organizations/routes/organizations.membershipRequest.routes.js +++ b/modules/organizations/routes/organizations.membershipRequest.routes.js @@ -36,6 +36,17 @@ export default (app) => { .all(passport.authenticate('jwt', { session: false })) .put(membershipRequests.accept); + // The INVITED USER declines a pending owner_add membership (deletes the row so + // they can be re-invited). Auth-only — same consent gate as accept, enforced in + // the service. Registered AFTER /mine and /mine/pending so those literals are + // never captured as a :membershipId. NOTE: there is intentionally no app.param + // for :membershipId — the controller reads req.params.membershipId raw, exactly + // like accept does. + app + .route('/api/membership-requests/:membershipId') + .all(passport.authenticate('jwt', { session: false })) + .delete(membershipRequests.decline); + // Create a request to join an organization / list pending requests for an organization app .route('/api/organizations/:organizationId/requests') diff --git a/modules/organizations/tests/organizations.decline.e2e.tests.js b/modules/organizations/tests/organizations.decline.e2e.tests.js new file mode 100644 index 000000000..258b33d8b --- /dev/null +++ b/modules/organizations/tests/organizations.decline.e2e.tests.js @@ -0,0 +1,175 @@ +/** + * @desc E2E tests for the owner_add decline flow (#3831). + * Tests: signup owner → signup invitee → owner adds invitee (pending owner_add, + * visible in the members list) → the owner CANNOT decline on the invitee's + * behalf (404, consent) → the invitee declines (row deleted) → the invitee can + * then request to join — proving the createJoinRequest copy + * 'Please accept or decline it' is honest. + */ +import request from 'supertest'; +import path from 'path'; + +import { bootstrap } from '../../../lib/app.js'; +import mongooseService from '../../../lib/services/mongoose.js'; +import config from '../../../config/index.js'; + +describe('Organizations owner_add decline E2E tests:', () => { + let UserService; + let OrganizationsRepository; + let MembershipRepository; + let agent; + + // Store original config + const originalOrganizations = { ...config.organizations }; + + /** + * @description Reset organizations config to original state. + */ + const resetOrgConfig = () => { + config.organizations = { ...originalOrganizations }; + }; + + /** + * @description Clean up a user and their associated organizations/memberships. + * @param {Object} user - The user object to clean up. + */ + const cleanupUser = async (user) => { + if (!user) return; + try { + const memberships = await MembershipRepository.list({ userId: user.id || user._id }); + for (const m of memberships) { + const orgId = m.organizationId._id || m.organizationId; + await MembershipRepository.deleteMany({ organizationId: orgId }); + await OrganizationsRepository.deleteMany({ _id: orgId }); + } + await UserService.remove(user); + } catch (_) { /* cleanup — ignore errors */ } + }; + + beforeAll(async () => { + try { + const init = await bootstrap(); + UserService = (await import(path.resolve('./modules/users/services/users.service.js'))).default; + OrganizationsRepository = (await import(path.resolve('./modules/organizations/repositories/organizations.repository.js'))).default; + MembershipRepository = (await import(path.resolve('./modules/organizations/repositories/organizations.membership.repository.js'))).default; + agent = request.agent(init.app); + } catch (err) { + console.log(err); + expect(err).toBeFalsy(); + } + }); + + describe('Full decline flow', () => { + let owner; + let invitee; + let org; + let agentOwner; + let agentInvitee; + + afterAll(async () => { + try { + if (org) { + await MembershipRepository.deleteMany({ organizationId: org._id }); + await OrganizationsRepository.deleteMany({ _id: org._id }); + } + } catch (_) { /* cleanup */ } + await cleanupUser(invitee); + await cleanupUser(owner); + }); + + test('owner adds → invite visible → owner cannot decline → invitee declines → invitee can request to join', async () => { + config.organizations = { enabled: true, autoCreate: true, domainMatching: false }; + agentOwner = request.agent(agent.app); + agentInvitee = request.agent(agent.app); + + // Step 1: signup the owner, auto-creates the org + const resultOwner = await agentOwner + .post('/api/auth/signup') + .send({ + firstName: 'DeclineOwner', + lastName: 'User', + email: 'decline-owner@test.com', + password: 'W@os.jsI$Aw3$0m3', + provider: 'local', + }) + .expect(200); + owner = resultOwner.body.user; + org = resultOwner.body.organization; + expect(org).toBeDefined(); + expect(org).not.toBeNull(); + + // Step 2: signup the invitee + const resultInvitee = await agentInvitee + .post('/api/auth/signup') + .send({ + firstName: 'DeclineInvitee', + lastName: 'User', + email: 'decline-invitee@test.com', + password: 'W@os.jsI$Aw3$0m3', + provider: 'local', + }) + .expect(200); + invitee = resultInvitee.body.user; + + // Step 3: owner adds the invitee → PENDING owner_add membership + const addResult = await agentOwner + .post(`/api/organizations/${org._id}/members`) + .send({ userId: invitee.id }) + .expect(200); + expect(addResult.body.message).toBe('membership invitation created'); + const invitationId = addResult.body.data._id; + expect(invitationId).toBeDefined(); + + // Step 4: the pending invite is now VISIBLE in the owner's members list, + // with status + source so the UI can render the pending state. + const membersResult = await agentOwner + .get(`/api/organizations/${org._id}/members`) + .expect(200); + const pendingRow = membersResult.body.data.find((m) => m._id === invitationId); + expect(pendingRow).toBeDefined(); + expect(pendingRow.status).toBe('pending'); + expect(pendingRow.source).toBe('owner_add'); + + // Step 5: the OWNER cannot decline on the invitee's behalf → 404 (consent + // gate, same opaque copy as accept) and the row survives. + await agentOwner + .delete(`/api/membership-requests/${invitationId}`) + .expect(404); + const stillThere = await MembershipRepository.findOne({ + userId: invitee.id, + organizationId: org._id, + status: 'pending', + }); + expect(stillThere).not.toBeNull(); + + // Step 6: the INVITEE declines → 200, row DELETED (not flagged) + const declineResult = await agentInvitee + .delete(`/api/membership-requests/${invitationId}`) + .expect(200); + expect(declineResult.body.message).toBe('membership invitation declined'); + const deleted = await MembershipRepository.findOne({ + userId: invitee.id, + organizationId: org._id, + }); + expect(deleted).toBeNull(); + + // Step 7: the 'Please accept or decline it' copy is honest — having + // declined, the invitee can now request to join (no pending row blocks it). + const joinResult = await agentInvitee + .post(`/api/organizations/${org._id}/requests`) + .expect(200); + expect(joinResult.body.message).toBe('membership request created'); + }); + }); + + // Mongoose disconnect + afterAll(async () => { + resetOrgConfig(); + try { + await mongooseService.disconnect(); + } catch (err) { + console.log(err); + expect(err).toBeFalsy(); + } + }); +}); From 29fb68ba95001120b32f210269c337925440eef3 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Sat, 13 Jun 2026 17:29:09 +0200 Subject: [PATCH 4/6] feat(organizations): send invitation email on owner add-member MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit addMember created a pending owner_add membership silently while all three join-request paths email their counterpart — an org-less invitee got zero signal. Notify the invited user via the new org-member-added template with invitation wording (the membership stays PENDING until THEY accept, consent invariant #1), fire-and-forget with logger.warn on failure, guarded by mailer.isConfigured() like the existing membership emails. refs #3832 --- config/templates/org-member-added.html | 8 ++ .../organizations.membership.service.js | 28 ++++- ...s.membership.addMember.email.unit.tests.js | 118 ++++++++++++++++++ ...ions.membership.silent.catch.unit.tests.js | 21 +++- 4 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 config/templates/org-member-added.html create mode 100644 modules/organizations/tests/organizations.membership.addMember.email.unit.tests.js diff --git a/config/templates/org-member-added.html b/config/templates/org-member-added.html new file mode 100644 index 000000000..8937cb020 --- /dev/null +++ b/config/templates/org-member-added.html @@ -0,0 +1,8 @@ + + + +

Hello {{displayName}},

+

You have been invited to join {{orgName}} on {{appName}}.

+

Review and accept the invitation here: {{url}}

+

The {{appName}} Team.

+ diff --git a/modules/organizations/services/organizations.membership.service.js b/modules/organizations/services/organizations.membership.service.js index 07bc0174b..a75b4e138 100644 --- a/modules/organizations/services/organizations.membership.service.js +++ b/modules/organizations/services/organizations.membership.service.js @@ -366,7 +366,10 @@ const findUserByExactEmail = async (email) => { * @param {String} userId - The user being invited. * @param {String} [role] - The role to grant on acceptance (defaults to MEMBER). * @param {String} addedBy - The id of the owner/admin performing the add (provenance). - * @returns {Promise} The created PENDING owner_add membership. + * @returns {Promise} The created PENDING owner_add membership. When the + * mailer is configured, also notifies the invited user by email (invitation + * wording — the membership awaits THEIR acceptance) with a link to their + * organizations page. Fire-and-forget: a mail failure never fails the add. * @throws {Error} If userId is missing, the user does not exist, or a membership already exists. */ const addMember = async (organizationId, userId, role, addedBy) => { @@ -387,7 +390,7 @@ const addMember = async (organizationId, userId, role, addedBy) => { } // status EXPLICIT — never rely on the schema 'active' default (consent bypass). - return MembershipRepository.create({ + const membership = await MembershipRepository.create({ userId, organizationId, role: role || MEMBERSHIP_ROLES.MEMBER, @@ -395,6 +398,27 @@ const addMember = async (organizationId, userId, role, addedBy) => { source: PENDING_SOURCES.OWNER_ADD, addedBy, }); + + // Invitation notification (parity with the join-request emails). The membership + // is PENDING — only the invitee can activate it via acceptMembership (consent + // invariant #1) — so the wording is an invitation, never a "you were added" + // fait accompli. Fire-and-forget: a mail failure must never fail the add. + const org = await OrganizationRepository.get(organizationId); + if (mailer.isConfigured() && user?.email && org?.name) { + mailer.sendMail({ + to: user.email, + subject: `You have been invited to join ${org.name}`, + template: 'org-member-added', + params: { + displayName: [user.firstName, user.lastName].filter(Boolean).join(' '), + orgName: org.name, + appName: config.app.title, + url: `${getBaseUrl()}/users/organizations`, + }, + }).catch((err) => logger.warn('organizations.membership.addMember: invitation email failed', { message: err?.message, stack: err?.stack })); + } + + return membership; }; /** diff --git a/modules/organizations/tests/organizations.membership.addMember.email.unit.tests.js b/modules/organizations/tests/organizations.membership.addMember.email.unit.tests.js new file mode 100644 index 000000000..453a39b66 --- /dev/null +++ b/modules/organizations/tests/organizations.membership.addMember.email.unit.tests.js @@ -0,0 +1,118 @@ +/** + * Module dependencies. + */ +import { jest, describe, test, expect, beforeEach } from '@jest/globals'; + +import { MEMBERSHIP_ROLES } from '../lib/constants.js'; + +/** + * Unit tests — addMember invitation email (#3832). + * The owner-add creates a PENDING membership the invitee must ACCEPT (consent + * invariant #1), so the notification is an INVITATION — never a "you were + * added" fait accompli: + * - mailer configured → sendMail called once with the exact invite payload; + * - mailer unconfigured → sendMail never called, membership still created; + * - user without email → sendMail never called, membership still created. + */ + +const mockWarn = jest.fn(); +jest.unstable_mockModule('../../../lib/services/logger.js', () => ({ + default: { warn: mockWarn, error: jest.fn(), info: jest.fn() }, +})); + +const mockGetBrut = jest.fn(); +jest.unstable_mockModule('../../users/services/users.service.js', () => ({ + default: { getBrut: mockGetBrut, updateById: jest.fn().mockResolvedValue({}) }, +})); + +const mockFindOne = jest.fn(); +const mockCreate = jest.fn(); +jest.unstable_mockModule('../repositories/organizations.membership.repository.js', () => ({ + default: { + findOne: mockFindOne, + create: mockCreate, + update: jest.fn(), + remove: jest.fn(), + list: jest.fn(), + count: jest.fn(), + get: jest.fn(), + }, +})); + +const mockOrgGet = jest.fn(); +jest.unstable_mockModule('../repositories/organizations.repository.js', () => ({ + default: { get: mockOrgGet }, +})); + +const mockIsConfigured = jest.fn(); +const mockSendMail = jest.fn(); +jest.unstable_mockModule('../../../lib/helpers/mailer/index.js', () => ({ + default: { isConfigured: mockIsConfigured, sendMail: mockSendMail }, +})); + +jest.unstable_mockModule('../../../lib/helpers/getBaseUrl.js', () => ({ + default: jest.fn().mockReturnValue('http://localhost:3000'), +})); + +jest.unstable_mockModule('../../../config/index.js', () => ({ + default: { app: { title: 'Test' } }, +})); + +jest.unstable_mockModule('../../../lib/helpers/emailVerification.js', () => ({ + assertEmailVerified: jest.fn(), +})); + +const { default: MembershipService } = await import('../services/organizations.membership.service.js'); + +describe('organizations.membership.service addMember invitation email:', () => { + const user = { _id: 'u1', email: 'invitee@x.com', firstName: 'Ada', lastName: 'Lovelace' }; + const org = { _id: 'org1', name: 'TestOrg' }; + + beforeEach(() => { + jest.clearAllMocks(); + mockGetBrut.mockResolvedValue({ ...user }); + mockFindOne.mockResolvedValue(null); + mockCreate.mockImplementation((data) => Promise.resolve({ _id: 'm-new', ...data })); + mockOrgGet.mockResolvedValue(org); + mockSendMail.mockResolvedValue({}); + }); + + test('mailer configured: sends the invitation email with the exact payload', async () => { + mockIsConfigured.mockReturnValue(true); + + const membership = await MembershipService.addMember('org1', 'u1', MEMBERSHIP_ROLES.MEMBER, 'owner1'); + + expect(membership._id).toBe('m-new'); + expect(mockSendMail).toHaveBeenCalledTimes(1); + expect(mockSendMail).toHaveBeenCalledWith({ + to: 'invitee@x.com', + subject: 'You have been invited to join TestOrg', + template: 'org-member-added', + params: { + displayName: 'Ada Lovelace', + orgName: 'TestOrg', + appName: 'Test', + url: 'http://localhost:3000/users/organizations', + }, + }); + }); + + test('mailer NOT configured: creates the membership and never calls sendMail', async () => { + mockIsConfigured.mockReturnValue(false); + + const membership = await MembershipService.addMember('org1', 'u1', MEMBERSHIP_ROLES.MEMBER, 'owner1'); + + expect(membership._id).toBe('m-new'); + expect(mockSendMail).not.toHaveBeenCalled(); + }); + + test('user without an email: creates the membership and never calls sendMail', async () => { + mockIsConfigured.mockReturnValue(true); + mockGetBrut.mockResolvedValue({ _id: 'u1', firstName: 'Ada', lastName: 'Lovelace' }); + + const membership = await MembershipService.addMember('org1', 'u1', MEMBERSHIP_ROLES.MEMBER, 'owner1'); + + expect(membership._id).toBe('m-new'); + expect(mockSendMail).not.toHaveBeenCalled(); + }); +}); diff --git a/modules/organizations/tests/organizations.membership.silent.catch.unit.tests.js b/modules/organizations/tests/organizations.membership.silent.catch.unit.tests.js index f02190177..f391d2b74 100644 --- a/modules/organizations/tests/organizations.membership.silent.catch.unit.tests.js +++ b/modules/organizations/tests/organizations.membership.silent.catch.unit.tests.js @@ -5,7 +5,8 @@ import { jest, describe, test, expect } from '@jest/globals'; /** * Unit tests — verify that logger.warn is called when fire-and-forget emails - * fail in organizations.membership.service (createJoinRequest, approveRequest, rejectRequest). + * fail in organizations.membership.service (createJoinRequest, approveRequest, + * rejectRequest, addMember). */ const mockWarn = jest.fn(); @@ -131,4 +132,22 @@ describe('organizations.membership.service silent-catch error logging:', () => { { message: emailError.message, stack: emailError.stack }, ); }); + + test('addMember: should call logger.warn when invitation email fails', async () => { + mockWarn.mockClear(); + mockUserGetBrut.mockResolvedValueOnce({ _id: 'u1', email: 'user@x.com', firstName: 'A', lastName: 'B' }); + mockMembershipFindOne.mockResolvedValueOnce(null); + mockMembershipCreate.mockResolvedValueOnce({ _id: 'm-add' }); + mockOrgGet.mockResolvedValueOnce(org); + mockSendMail.mockRejectedValueOnce(emailError); + + await MembershipService.addMember('org1', 'u1', 'member', 'owner1'); + + await new Promise((r) => setTimeout(r, 20)); + + expect(mockWarn).toHaveBeenCalledWith( + 'organizations.membership.addMember: invitation email failed', + { message: emailError.message, stack: emailError.stack }, + ); + }); }); From 09f109fe67e717e2cc2a0c5ab430cb21acfb4c3b Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Sat, 13 Jun 2026 17:35:13 +0200 Subject: [PATCH 5/6] refactor(organizations): skip org fetch in addMember when mailer off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gate the OrganizationRepository.get behind mailer.isConfigured (matches approveRequest/rejectRequest) — no needless DB read on the mail-off path. refs #3832 --- .../organizations.membership.service.js | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/modules/organizations/services/organizations.membership.service.js b/modules/organizations/services/organizations.membership.service.js index a75b4e138..22a483c18 100644 --- a/modules/organizations/services/organizations.membership.service.js +++ b/modules/organizations/services/organizations.membership.service.js @@ -403,19 +403,21 @@ const addMember = async (organizationId, userId, role, addedBy) => { // is PENDING — only the invitee can activate it via acceptMembership (consent // invariant #1) — so the wording is an invitation, never a "you were added" // fait accompli. Fire-and-forget: a mail failure must never fail the add. - const org = await OrganizationRepository.get(organizationId); - if (mailer.isConfigured() && user?.email && org?.name) { - mailer.sendMail({ - to: user.email, - subject: `You have been invited to join ${org.name}`, - template: 'org-member-added', - params: { - displayName: [user.firstName, user.lastName].filter(Boolean).join(' '), - orgName: org.name, - appName: config.app.title, - url: `${getBaseUrl()}/users/organizations`, - }, - }).catch((err) => logger.warn('organizations.membership.addMember: invitation email failed', { message: err?.message, stack: err?.stack })); + if (mailer.isConfigured() && user?.email) { + const org = await OrganizationRepository.get(organizationId); + if (org?.name) { + mailer.sendMail({ + to: user.email, + subject: `You have been invited to join ${org.name}`, + template: 'org-member-added', + params: { + displayName: [user.firstName, user.lastName].filter(Boolean).join(' '), + orgName: org.name, + appName: config.app.title, + url: `${getBaseUrl()}/users/organizations`, + }, + }).catch((err) => logger.warn('organizations.membership.addMember: invitation email failed', { message: err?.message, stack: err?.stack })); + } } return membership; From febc4aa4732d372519da8d7cae6a0bf8fd6eb143 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Sat, 13 Jun 2026 17:54:46 +0200 Subject: [PATCH 6/6] fix(organizations): address CodeRabbit + Copilot review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - email template: add non-empty {{appName}} invitation (fixes title-require lint error flagged by CodeRabbit/HTMLHint) - addMember: wrap OrganizationRepository.get() in try/catch so any DB error inside the notification block never propagates to the caller; the fire-and-forget guarantee now covers the org-fetch step too (Copilot finding — line 421) - declineMembership: replace read-then-delete with atomic MembershipRepository.findOneAndDelete({_id, userId, status:PENDING, source:OWNER_ADD}) — eliminates the accept/decline race window where a row activated between get() and remove() could be wrongly deleted (CodeRabbit major finding — line 500); add findOneAndDelete() to repo - lifecycle unit tests: update declineMembership suite to mock/assert the new findOneAndDelete path; add concurrent-accept race scenario - membershipRequest controller unit tests: add 4 decline() cases (happy path, null→404, id fallback, throw→422) to close the 7-line patch coverage gap reported by codecov - addMember email unit tests: add expect(mockOrgGet).not.toHaveBeenCalled() to the two skip-email branches (CodeRabbit nit — line 117) - decline e2e tests: add @returns JSDoc tags to resetOrgConfig and cleanupUser named helpers (CodeRabbit nit — line 36) refs #3831 refs #3832 --- config/templates/org-member-added.html | 2 +- .../organizations.membership.repository.js | 13 ++++ .../organizations.membership.service.js | 57 ++++++++------- .../tests/organizations.decline.e2e.tests.js | 2 + ...s.membership.addMember.email.unit.tests.js | 2 + ...zations.membership.lifecycle.unit.tests.js | 70 +++++++++---------- ...membershipRequest.controller.unit.tests.js | 48 +++++++++++++ 7 files changed, 133 insertions(+), 61 deletions(-) diff --git a/config/templates/org-member-added.html b/config/templates/org-member-added.html index 8937cb020..12298e938 100644 --- a/config/templates/org-member-added.html +++ b/config/templates/org-member-added.html @@ -1,5 +1,5 @@ - +{{appName}} invitation

Hello {{displayName}},

You have been invited to join {{orgName}} on {{appName}}.

diff --git a/modules/organizations/repositories/organizations.membership.repository.js b/modules/organizations/repositories/organizations.membership.repository.js index fa6e0cdee..e9eefe16c 100644 --- a/modules/organizations/repositories/organizations.membership.repository.js +++ b/modules/organizations/repositories/organizations.membership.repository.js @@ -79,6 +79,18 @@ const update = (membership) => membership.save().then((doc) => doc.populate(defa */ const remove = (membership) => Membership.deleteOne({ _id: membership.id || membership._id }).exec(); +/** + * @function findOneAndDelete + * @description Atomically find a single membership matching a filter and delete it. + * Returns the deleted document (populated) or null if no document matched, allowing + * callers to implement consent-gated deletes in a single round-trip that is free + * of read-then-delete race conditions. + * @param {Object} filter - The filter to match the document to delete. + * @returns {Promise} The deleted membership (populated) or null. + */ +const findOneAndDelete = (filter) => + Membership.findOneAndDelete(filter).populate(defaultPopulate).exec(); + /** * @function count * @description Data access operation to count memberships matching a filter. @@ -124,6 +136,7 @@ export default { create, get, findOne, + findOneAndDelete, update, remove, count, diff --git a/modules/organizations/services/organizations.membership.service.js b/modules/organizations/services/organizations.membership.service.js index 22a483c18..b920d66c4 100644 --- a/modules/organizations/services/organizations.membership.service.js +++ b/modules/organizations/services/organizations.membership.service.js @@ -402,21 +402,26 @@ const addMember = async (organizationId, userId, role, addedBy) => { // Invitation notification (parity with the join-request emails). The membership // is PENDING — only the invitee can activate it via acceptMembership (consent // invariant #1) — so the wording is an invitation, never a "you were added" - // fait accompli. Fire-and-forget: a mail failure must never fail the add. + // fait accompli. Fire-and-forget: any failure (DB read or mailer) must never + // fail the add — the entire block is in a try/catch for that guarantee. if (mailer.isConfigured() && user?.email) { - const org = await OrganizationRepository.get(organizationId); - if (org?.name) { - mailer.sendMail({ - to: user.email, - subject: `You have been invited to join ${org.name}`, - template: 'org-member-added', - params: { - displayName: [user.firstName, user.lastName].filter(Boolean).join(' '), - orgName: org.name, - appName: config.app.title, - url: `${getBaseUrl()}/users/organizations`, - }, - }).catch((err) => logger.warn('organizations.membership.addMember: invitation email failed', { message: err?.message, stack: err?.stack })); + try { + const org = await OrganizationRepository.get(organizationId); + if (org?.name) { + mailer.sendMail({ + to: user.email, + subject: `You have been invited to join ${org.name}`, + template: 'org-member-added', + params: { + displayName: [user.firstName, user.lastName].filter(Boolean).join(' '), + orgName: org.name, + appName: config.app.title, + url: `${getBaseUrl()}/users/organizations`, + }, + }).catch((err) => logger.warn('organizations.membership.addMember: invitation email failed', { message: err?.message, stack: err?.stack })); + } + } catch (err) { + logger.warn('organizations.membership.addMember: invitation email setup failed', { message: err?.message, stack: err?.stack }); } } @@ -486,18 +491,22 @@ const acceptMembership = async (membershipId, acceptingUserId) => { */ const declineMembership = async (membershipId, decliningUserId) => { // Self-defending consent gate, same rationale as acceptMembership: reject an - // absent caller up-front so the String(undefined) compare can never collide. + // absent caller up-front so a null/undefined id never accidentally matches a + // document (MongoDB $eq null matches null + missing fields). if (!decliningUserId) return null; - const membership = await MembershipRepository.get(membershipId); - if (!membership) return null; - - const membershipUserId = String(membership.userId?._id || membership.userId); - const isOwnerAddPending = membership.status === MEMBERSHIP_STATUSES.PENDING - && membership.source === PENDING_SOURCES.OWNER_ADD; - if (!isOwnerAddPending || membershipUserId !== String(decliningUserId)) return null; - await MembershipRepository.remove(membership); - return membership; + // Atomic consent-gated delete: the filter encodes ALL consent conditions so no + // separate read is needed. A concurrent accept that flipped status→ACTIVE will + // cause the filter to miss the document (status !== PENDING), safely returning + // null — the accept wins and the decline is silently a no-op, which is correct + // (the invitee accepted; there is nothing left to decline). + const deleted = await MembershipRepository.findOneAndDelete({ + _id: membershipId, + userId: decliningUserId, + status: MEMBERSHIP_STATUSES.PENDING, + source: PENDING_SOURCES.OWNER_ADD, + }); + return deleted || null; }; /** diff --git a/modules/organizations/tests/organizations.decline.e2e.tests.js b/modules/organizations/tests/organizations.decline.e2e.tests.js index 258b33d8b..6cc223494 100644 --- a/modules/organizations/tests/organizations.decline.e2e.tests.js +++ b/modules/organizations/tests/organizations.decline.e2e.tests.js @@ -24,6 +24,7 @@ describe('Organizations owner_add decline E2E tests:', () => { /** * @description Reset organizations config to original state. + * @returns {void} */ const resetOrgConfig = () => { config.organizations = { ...originalOrganizations }; @@ -32,6 +33,7 @@ describe('Organizations owner_add decline E2E tests:', () => { /** * @description Clean up a user and their associated organizations/memberships. * @param {Object} user - The user object to clean up. + * @returns {Promise} */ const cleanupUser = async (user) => { if (!user) return; diff --git a/modules/organizations/tests/organizations.membership.addMember.email.unit.tests.js b/modules/organizations/tests/organizations.membership.addMember.email.unit.tests.js index 453a39b66..c0a5928b4 100644 --- a/modules/organizations/tests/organizations.membership.addMember.email.unit.tests.js +++ b/modules/organizations/tests/organizations.membership.addMember.email.unit.tests.js @@ -104,6 +104,7 @@ describe('organizations.membership.service addMember invitation email:', () => { expect(membership._id).toBe('m-new'); expect(mockSendMail).not.toHaveBeenCalled(); + expect(mockOrgGet).not.toHaveBeenCalled(); }); test('user without an email: creates the membership and never calls sendMail', async () => { @@ -114,5 +115,6 @@ describe('organizations.membership.service addMember invitation email:', () => { expect(membership._id).toBe('m-new'); expect(mockSendMail).not.toHaveBeenCalled(); + expect(mockOrgGet).not.toHaveBeenCalled(); }); }); diff --git a/modules/organizations/tests/organizations.membership.lifecycle.unit.tests.js b/modules/organizations/tests/organizations.membership.lifecycle.unit.tests.js index 3256f83c8..59bf8b815 100644 --- a/modules/organizations/tests/organizations.membership.lifecycle.unit.tests.js +++ b/modules/organizations/tests/organizations.membership.lifecycle.unit.tests.js @@ -24,6 +24,7 @@ const mockGet = jest.fn(); const mockList = jest.fn(); const mockCount = jest.fn(); const mockRemove = jest.fn(); +const mockFindOneAndDelete = jest.fn(); jest.unstable_mockModule('../repositories/organizations.membership.repository.js', () => ({ default: { findOne: mockFindOne, @@ -33,6 +34,7 @@ jest.unstable_mockModule('../repositories/organizations.membership.repository.js list: mockList, count: mockCount, remove: mockRemove, + findOneAndDelete: mockFindOneAndDelete, }, })); @@ -77,7 +79,7 @@ describe('Membership owner_add lifecycle unit tests:', () => { mockRemove.mockResolvedValue({ deletedCount: 1 }); }); - describe('declineMembership — consent gate (mirror of acceptMembership)', () => { + describe('declineMembership — atomic consent-gated delete', () => { /** * @desc Build a pending owner_add membership owned by USER. * @param {Object} overrides - field overrides @@ -92,65 +94,61 @@ describe('Membership owner_add lifecycle unit tests:', () => { ...overrides, }); - test('the INVITED USER can decline their own pending owner_add → row deleted and returned', async () => { + test('the INVITED USER can decline their own pending owner_add → deleted doc returned', async () => { const membership = ownerAddPending(); - mockGet.mockResolvedValue(membership); + mockFindOneAndDelete.mockResolvedValue(membership); const result = await MembershipService.declineMembership('m1', USER); expect(result).toBe(membership); - expect(mockRemove).toHaveBeenCalledTimes(1); - expect(mockRemove).toHaveBeenCalledWith(membership); - }); - - test('a DIFFERENT user (e.g. the inviting owner) cannot decline → null, no deletion', async () => { - mockGet.mockResolvedValue(ownerAddPending()); - - const result = await MembershipService.declineMembership('m1', OWNER); - - expect(result).toBeNull(); - expect(mockRemove).not.toHaveBeenCalled(); - }); - - test('a JOIN REQUEST row cannot be declined here (owner reject path owns it) → null', async () => { - mockGet.mockResolvedValue(ownerAddPending({ source: PENDING_SOURCES.JOIN_REQUEST })); - - const result = await MembershipService.declineMembership('m1', USER); - - expect(result).toBeNull(); + expect(mockFindOneAndDelete).toHaveBeenCalledTimes(1); + expect(mockFindOneAndDelete).toHaveBeenCalledWith({ + _id: 'm1', + userId: USER, + status: MEMBERSHIP_STATUSES.PENDING, + source: PENDING_SOURCES.OWNER_ADD, + }); expect(mockRemove).not.toHaveBeenCalled(); }); - test('an ACTIVE membership cannot be declined (leave/remove own that path) → null', async () => { - mockGet.mockResolvedValue(ownerAddPending({ status: MEMBERSHIP_STATUSES.ACTIVE })); + test('a DIFFERENT user (e.g. the inviting owner) cannot decline → null (filter miss)', async () => { + // findOneAndDelete with userId=OWNER will find nothing (userId mismatch) + mockFindOneAndDelete.mockResolvedValue(null); - const result = await MembershipService.declineMembership('m1', USER); + const result = await MembershipService.declineMembership('m1', OWNER); expect(result).toBeNull(); + // called with OWNER as userId — the DB filter correctly rejects cross-user deletes + expect(mockFindOneAndDelete).toHaveBeenCalledWith({ + _id: 'm1', + userId: OWNER, + status: MEMBERSHIP_STATUSES.PENDING, + source: PENDING_SOURCES.OWNER_ADD, + }); expect(mockRemove).not.toHaveBeenCalled(); }); - test('an unknown membership id → null', async () => { - mockGet.mockResolvedValue(null); + test('unknown membership id → null (findOneAndDelete returns null)', async () => { + mockFindOneAndDelete.mockResolvedValue(null); const result = await MembershipService.declineMembership('missing', USER); expect(result).toBeNull(); - expect(mockRemove).not.toHaveBeenCalled(); }); - test('SELF-DEFENDING GATE: an undefined decliningUserId → null, no deletion', async () => { - mockGet.mockResolvedValue(ownerAddPending()); + test('SELF-DEFENDING GATE: an undefined decliningUserId → null, findOneAndDelete never called', async () => { const result = await MembershipService.declineMembership('m1', undefined); expect(result).toBeNull(); - expect(mockRemove).not.toHaveBeenCalled(); + expect(mockFindOneAndDelete).not.toHaveBeenCalled(); }); - test('matches the invitee even when userId is a populated sub-doc', async () => { - const membership = ownerAddPending({ userId: { _id: USER, email: 'a@b.com' } }); - mockGet.mockResolvedValue(membership); + test('concurrent accept wins: findOneAndDelete returns null (status no longer PENDING) → null', async () => { + // Simulates a race where acceptMembership flipped status→ACTIVE after our + // decliningUserId guard passed but before deletion; filter misses the doc. + mockFindOneAndDelete.mockResolvedValue(null); const result = await MembershipService.declineMembership('m1', USER); - expect(result).toBe(membership); - expect(mockRemove).toHaveBeenCalledWith(membership); + + expect(result).toBeNull(); + expect(mockRemove).not.toHaveBeenCalled(); }); }); diff --git a/modules/organizations/tests/organizations.membershipRequest.controller.unit.tests.js b/modules/organizations/tests/organizations.membershipRequest.controller.unit.tests.js index cb26dcd6e..542494277 100644 --- a/modules/organizations/tests/organizations.membershipRequest.controller.unit.tests.js +++ b/modules/organizations/tests/organizations.membershipRequest.controller.unit.tests.js @@ -14,6 +14,7 @@ import { MEMBERSHIP_STATUSES, PENDING_SOURCES } from '../lib/constants.js'; const mockGet = jest.fn(); const mockAcceptMembership = jest.fn(); +const mockDeclineMembership = jest.fn(); const mockListPendingOwnerAddsByUser = jest.fn(); const mockListPendingByUser = jest.fn(); @@ -21,6 +22,7 @@ jest.unstable_mockModule('../services/organizations.membership.service.js', () = default: { get: mockGet, acceptMembership: mockAcceptMembership, + declineMembership: mockDeclineMembership, listPendingOwnerAddsByUser: mockListPendingOwnerAddsByUser, listPendingByUser: mockListPendingByUser, listPending: jest.fn(), @@ -134,4 +136,50 @@ describe('membershipRequest controller — consent surfaces:', () => { expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ data: [{ id: 'inv1' }] })); }); }); + + describe('decline (invited user declines their owner_add)', () => { + test('passes the membershipId + the AUTHENTICATED user to the service', async () => { + const deleted = { id: 'm1', status: 'pending', source: 'owner_add' }; + mockDeclineMembership.mockResolvedValue(deleted); + const req = { params: { membershipId: 'm1' }, user: { _id: 'invitee' } }; + const res = mockRes(); + + await controller.decline(req, res); + + expect(mockDeclineMembership).toHaveBeenCalledWith('m1', 'invitee'); + expect(res.status).not.toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ data: deleted })); + }); + + test('a null service result (consent mismatch / not found) → 404, never leaks which', async () => { + mockDeclineMembership.mockResolvedValue(null); + const req = { params: { membershipId: 'm1' }, user: { _id: 'someone-else' } }; + const res = mockRes(); + + await controller.decline(req, res); + + expect(res.status).toHaveBeenCalledWith(404); + }); + + test('falls back to req.user.id when _id is absent', async () => { + const deleted = { id: 'm2', status: 'pending', source: 'owner_add' }; + mockDeclineMembership.mockResolvedValue(deleted); + const req = { params: { membershipId: 'm2' }, user: { id: 'invitee2' } }; + const res = mockRes(); + + await controller.decline(req, res); + + expect(mockDeclineMembership).toHaveBeenCalledWith('m2', 'invitee2'); + }); + + test('service throws → 422 Unprocessable Entity', async () => { + mockDeclineMembership.mockRejectedValue(new Error('db error')); + const req = { params: { membershipId: 'm1' }, user: { _id: 'u1' } }; + const res = mockRes(); + + await controller.decline(req, res); + + expect(res.status).toHaveBeenCalledWith(422); + }); + }); });