diff --git a/MIGRATIONS.md b/MIGRATIONS.md index e4a6ca4a9..577c5b4f2 100644 --- a/MIGRATIONS.md +++ b/MIGRATIONS.md @@ -4,6 +4,27 @@ Breaking changes and upgrade notes for downstream projects. --- +## org.addMember + membership consent split (2026-06-10) + +Phase 5a of the invitations↔org decouple epic (#3813). Replaces the deleted org email-invite with a **consent-safe add-member flow**: an owner/admin adds an *existing* user, creating a **PENDING `owner_add`** membership that the **invited user** must accept — the owner can NEVER approve it (consent invariant). + +### What changed (this repo) + +- **Membership model** (`organizations.membership.model.mongoose.js`) — new `source` enum field `{ 'join_request', 'owner_add' }` **with NO default** + a `pre('validate')` hook that throws if a PENDING row has no `source` (a forgotten source must fail loudly, never silently become an owner-approvable join request). New optional `addedBy` (ObjectId, audit-only). +- **Constants** — new `PENDING_SOURCES = { JOIN_REQUEST:'join_request', OWNER_ADD:'owner_add' }`. +- **Service** (`organizations.membership.service.js`) — `addMember(orgId, userId, role, addedBy)` creates a PENDING owner_add (status set EXPLICITLY — the schema defaults status to `'active'`); rejects if ANY membership already exists for (user, org); last-owner-safe. `acceptMembership(id, userId)` flips PENDING→ACTIVE **only** for a `source:'owner_add'` membership whose `userId` is the caller (sets `currentOrganization` if unset). `createJoinRequest`'s single-pending-global rule is now **source-scoped to join_request** (a pending owner_add no longer blocks a join request, and vice-versa). New `listPendingOwnerAddsByUser`. +- **Approval surface scoped to join_request** — `listPending`, `listPendingByUser`, and the `requestByID` approve/reject gate now match `source:'join_request'` (with an E17 `source $exists:false` legacy fallback) so an owner_add is **invisible** to the owner-approval surface. The auth-payload `pendingRequests` is unchanged in shape — still the user's own join requests. +- **Routes** — `POST /api/organizations/:organizationId/members` (owner/admin; CASL `create Membership`) adds a member; `GET /api/organizations/:organizationId/members/search?email=` (owner/admin) looks up a user by **exact email** (GDPR: no fuzzy directory enumeration); `GET /api/membership-requests/mine/pending` lists the user's pending owner_add invitations; `PUT /api/membership-requests/:membershipId/accept` lets the **invited user** accept (auth-only; consent gate in the service, no org-CASL). +- **Migration** `modules/organizations/migrations/20260610140000-backfill-membership-source.js`: sets `source:'join_request'` on all existing PENDING memberships (they were all join requests pre-change). Idempotent (filter requires `source` absent). Raw collection driver (house style). + +### Action required for downstream projects (`/update-project`) + +1. Module/model/migration changes are devkit-owned → arrive via `/update-stack` (`--theirs`). +2. **Migration ORDERING (E17 — critical):** the backfill `20260610140000` MUST run BEFORE the source-filtering code deploys, so no pre-existing join request is hidden from the approval list. The migration runs at boot before `listen()`; on Trawl this is sequenced in epic Phase 9 (#3815). The service/controller carry a temporary `source $exists:false` fallback so legacy rows stay visible even if the code lands first; that fallback is removed in a follow-up once every environment's backfill is confirmed. +3. No platform-invitation (`sign.cap` / `?inviteToken=`) behavior changes. Vue add-member UI + pending-invitation list land in Vue #4281. + +--- + ## Remove organization email-invite feature (2026-06-10) Phase 4 of the invitations↔org decouple epic (#3812). The organization's **own** email-invite flow is **deleted** — distinct from the platform `invitations` module (single-use signup-token gate), which is unchanged. This is the owner-invites-an-email-to-join-their-org flow that lived on the membership doc as `status:'invited'` + `inviteToken` / `invitedEmail` / `inviteExpiresAt`. The 2-step "invite to platform, then add the resulting user as a member" flow replaces it (`org.addMember` lands in a later phase / #3813). diff --git a/modules/organizations/controllers/organizations.membership.controller.js b/modules/organizations/controllers/organizations.membership.controller.js index eea4c0408..d2f2a41a5 100644 --- a/modules/organizations/controllers/organizations.membership.controller.js +++ b/modules/organizations/controllers/organizations.membership.controller.js @@ -26,6 +26,77 @@ const list = async (req, res) => { } }; +/** + * @function findUserByEmail + * @description Endpoint for an org owner/admin to look up a single user by EXACT + * email, to add them as a member (P5b affordance). GDPR/privacy: deliberately + * exact-match only (no fuzzy name enumeration of the user directory) and gated by + * the same owner/admin CASL `create Membership` ability the add-member route uses. + * Returns minimal identity (id, displayName, email) for a match, or null. + * @param {Object} req - Express request object + * @param {Object} res - Express response object + * @returns {void} + */ +const findUserByEmail = async (req, res) => { + try { + // CASL on the GET /members surface grants `read Membership` to plain members too, + // which is too broad for a user-directory lookup. Restrict to owner/admin (or + // global admin) explicitly — only roles that can actually add a member. + const isPlatformAdmin = isGlobalAdmin(req.user); + const actorRole = req.membership?.role; + const canEnumerate = isPlatformAdmin + || actorRole === MEMBERSHIP_ROLES.OWNER + || actorRole === MEMBERSHIP_ROLES.ADMIN; + if (!canEnumerate) { + return responses.error(res, 403, 'Forbidden', 'Only owners or admins can look up users')(); + } + + const email = req.query.email; + if (!email || typeof email !== 'string') { + return responses.error(res, 422, 'Unprocessable Entity', 'An email query parameter is required')(); + } + const match = await MembershipService.findUserByExactEmail(email); + responses.success(res, 'user lookup')(match); + } catch (err) { + responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err); + } +}; + +/** + * @function addMember + * @description Endpoint for an org owner/admin to add a user as a member. Creates a + * PENDING owner_add membership the INVITED USER must accept (consent — invariant #1). + * Role gate mirrors updateRole: only OWNERS may grant owner/admin; admins may only + * add plain members. CASL (`create Membership`) on the /members POST route already + * restricts this to org owners/admins (+ global admins). + * @param {Object} req - Express request object + * @param {Object} res - Express response object + * @returns {void} + */ +const addMember = async (req, res) => { + try { + const { userId, role } = req.body; + const requestedRole = role || MEMBERSHIP_ROLES.MEMBER; + + // Elevated-role guard: only owners (or global admins) may invite an owner/admin. + const isPlatformAdmin = isGlobalAdmin(req.user); + const actorIsOwner = req.membership?.role === MEMBERSHIP_ROLES.OWNER; + if (requestedRole !== MEMBERSHIP_ROLES.MEMBER && !isPlatformAdmin && !actorIsOwner) { + return responses.error(res, 403, 'Forbidden', 'Only owners can add a member with an elevated role')(); + } + + const membership = await MembershipService.addMember( + req.organization._id || req.organization.id, + userId, + requestedRole, + req.user._id || req.user.id, + ); + responses.success(res, 'membership invitation created')(membership); + } catch (err) { + responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err); + } +}; + /** * @function updateRole * @description Endpoint to change the role of a member in an organization. @@ -110,6 +181,8 @@ const memberByID = async (req, res, next, id) => { export default { list, + findUserByEmail, + addMember, updateRole, remove, memberByID, diff --git a/modules/organizations/controllers/organizations.membershipRequest.controller.js b/modules/organizations/controllers/organizations.membershipRequest.controller.js index 8fdb57eb2..d53a9b0db 100644 --- a/modules/organizations/controllers/organizations.membershipRequest.controller.js +++ b/modules/organizations/controllers/organizations.membershipRequest.controller.js @@ -4,7 +4,7 @@ import errors from '../../../lib/helpers/errors.js'; import responses from '../../../lib/helpers/responses.js'; import MembershipService from '../services/organizations.membership.service.js'; -import { MEMBERSHIP_ROLES, MEMBERSHIP_STATUSES } from '../lib/constants.js'; +import { MEMBERSHIP_ROLES, MEMBERSHIP_STATUSES, isOwnerApprovable } from '../lib/constants.js'; /** * @function create @@ -81,7 +81,9 @@ const reject = async (req, res) => { /** * @function listMine - * @description Endpoint to list the authenticated user's own pending requests. + * @description Endpoint to list the authenticated user's own pending JOIN REQUESTS + * (requests they initiated, awaiting an owner's approval). Owner_add invitations + * to accept are a separate surface — see listMinePending. * @param {Object} req - Express request object * @param {Object} res - Express response object * @returns {Promise} @@ -95,6 +97,52 @@ const listMine = async (req, res) => { } }; +/** + * @function listMinePending + * @description Endpoint to list the authenticated user's pending OWNER_ADD + * invitations — memberships an owner/admin created for them that they must ACCEPT + * to activate. Feeds the Vue "pending invitations to accept" list (P5b). + * @param {Object} req - Express request object + * @param {Object} res - Express response object + * @returns {Promise} + */ +const listMinePending = async (req, res) => { + try { + const invitations = await MembershipService.listPendingOwnerAddsByUser(req.user._id || req.user.id); + responses.success(res, 'membership invitation list')(invitations); + } catch (err) { + responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err); + } +}; + +/** + * @function accept + * @description Endpoint for the INVITED USER to accept a pending owner_add membership. + * Authentication is required (passport); the consent gate lives in the service — + * acceptMembership activates ONLY when the membership is a PENDING owner_add AND + * belongs to the authenticated caller. A mismatch (wrong user, a join_request, an + * already-active or unknown membership) returns null → 404, never leaking which. + * No org membership / CASL check: the invitee is by definition NOT yet a member, + * so this route is intentionally outside the /members + /requests CASL surfaces. + * @param {Object} req - Express request object + * @param {Object} res - Express response object + * @returns {Promise} + */ +const accept = async (req, res) => { + try { + const membership = await MembershipService.acceptMembership( + 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 accepted')(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. @@ -109,7 +157,12 @@ const requestByID = async (req, res, next, id) => { const membership = await MembershipService.get(id); const organizationId = String(req.organization._id || req.organization.id); const membershipOrgId = String(membership?.organizationId?._id || membership?.organizationId); - if (!membership || membership.status !== MEMBERSHIP_STATUSES.PENDING || membershipOrgId !== organizationId) { + // CONSENT GATE (invariant #1): the owner-approval surface (approve/reject) must + // ONLY ever see JOIN REQUESTS. An owner_add awaits the INVITED USER's consent — + // it must be invisible here, else an owner could approve it and bypass consent. + // `isOwnerApprovable` is the shared source of truth (covers join_request + the + // E17 legacy no-source case); see its doc-level twin note on joinRequestSourceFilter. + if (!membership || membership.status !== MEMBERSHIP_STATUSES.PENDING || !isOwnerApprovable(membership.source) || membershipOrgId !== organizationId) { return responses.error(res, 404, 'Not Found', 'No pending request with that identifier has been found')(); } req.membershipRequest = membership; @@ -125,5 +178,7 @@ export default { approve, reject, listMine, + listMinePending, + accept, requestByID, }; diff --git a/modules/organizations/lib/constants.js b/modules/organizations/lib/constants.js index 3110eb593..bad152fd7 100644 --- a/modules/organizations/lib/constants.js +++ b/modules/organizations/lib/constants.js @@ -8,3 +8,43 @@ export const MEMBERSHIP_ROLES = { ADMIN: 'admin', MEMBER: 'member', }; + +/** + * Source discriminator for PENDING memberships — the consent split. + * + * A PENDING membership can exist for two distinct reasons, and the two MUST stay + * separable so the owner-approval surface never sees (and so never auto-approves) + * a membership the invited user has not consented to: + * - JOIN_REQUEST: the user asked to join (owner approves → ACTIVE). The owner is + * the one who consents; this is the legacy/default join flow. + * - OWNER_ADD: an owner/admin added a user (the INVITED USER accepts → ACTIVE). + * The owner must NEVER be able to approve this — only the invited user can, + * via acceptMembership. An owner_add appearing in the approval list would be a + * consent bypass. + * + * Stored on `membership.source`. There is intentionally NO schema default — a + * forgotten `source` on a PENDING row must fail loudly (pre('validate') guard) + * rather than silently default to a join_request the owner could approve. + */ +export const PENDING_SOURCES = { + JOIN_REQUEST: 'join_request', + OWNER_ADD: 'owner_add', +}; + +/** + * @function isOwnerApprovable + * @description Single source of truth for the consent invariant #1: a PENDING + * membership is owner-approvable (appears in the owner's join-request / approve + * surface) ONLY when it is NOT an owner_add. An owner_add requires the INVITED + * user's consent via acceptMembership, never the owner's approval — letting one + * into the approval surface would be a consent bypass. + * + * The doc-level twin of the DB-layer `joinRequestSourceFilter` `$or` query in + * `organizations.membership.service.js`; the two MUST stay in lock-step. A Mongo + * filter can't be a predicate, so the query stays separate — keep them aligned. + * E17: a legacy PENDING with no `source` (pre-backfill, all join_requests) is + * owner-approvable — `!== OWNER_ADD` covers both join_request and absent. + * @param {String} [source] - The membership's PENDING source discriminator. + * @returns {Boolean} True if an owner/admin may approve this PENDING membership. + */ +export const isOwnerApprovable = (source) => source !== PENDING_SOURCES.OWNER_ADD; diff --git a/modules/organizations/migrations/20260610140000-backfill-membership-source.js b/modules/organizations/migrations/20260610140000-backfill-membership-source.js new file mode 100644 index 000000000..a617e8114 --- /dev/null +++ b/modules/organizations/migrations/20260610140000-backfill-membership-source.js @@ -0,0 +1,62 @@ +/** + * Module dependencies + */ +import mongoose from 'mongoose'; + +import { MEMBERSHIP_STATUSES, PENDING_SOURCES } from '../lib/constants.js'; + +/** + * Migration: backfill `source` on existing PENDING memberships. + * + * The consent split (P5a) adds a `source` discriminator to PENDING memberships: + * - 'join_request' — the user asked to join (owner approves → ACTIVE); + * - 'owner_add' — an owner added the user (the invited user accepts → ACTIVE). + * + * Every PENDING membership that existed BEFORE this change was a join request (the + * owner_add flow did not exist), so they are all backfilled to 'join_request'. + * + * Why this matters (E17 migration ordering): the new code reads the owner-approval + * surface as `source: 'join_request' OR source absent` so legacy rows stay visible + * until this backfill runs. After this migration, every PENDING row carries an + * explicit source and the `$exists:false` fallback in the service/controller can be + * removed (follow-up). P9 MUST run this migration on Trawl BEFORE deploying any code + * that filters owner_adds out of the approval surface, so no join request is ever + * hidden. + * + * `source` IS declared on the schema now, so a model `updateMany` `$set` would NOT + * be stripped by strict mode. We nonetheless use the RAW collection driver to match + * the house migration style and to be robust regardless of model-registration state + * at migration time. ACTIVE memberships are intentionally left untouched (source is + * only meaningful while PENDING). + * + * Idempotent: the filter requires `source` ABSENT, so a second run matches nothing. + * @returns {Promise} Resolves when the backfill has completed. + */ +export async function up() { + // Model `Membership` → default collection `memberships`. + const db = mongoose.connection.db; + const memberships = db.collection('memberships'); + + const result = await memberships.updateMany( + { status: MEMBERSHIP_STATUSES.PENDING, source: { $exists: false } }, + { $set: { source: PENDING_SOURCES.JOIN_REQUEST } }, + ); + + const modified = result?.modifiedCount ?? 0; + console.info(`[migration] backfill-membership-source: set source='join_request' on ${modified} pending membership(s)`); +} + +/** + * Down migration: unset `source` on the rows this backfill set (PENDING + * join_requests). ACTIVE rows and owner_adds are untouched. Best-effort reversal. + * @returns {Promise} Resolves when the unset has completed. + */ +export async function down() { + const db = mongoose.connection.db; + const memberships = db.collection('memberships'); + await memberships.updateMany( + { status: MEMBERSHIP_STATUSES.PENDING, source: PENDING_SOURCES.JOIN_REQUEST }, + { $unset: { source: '' } }, + ); + console.warn('[migration] backfill-membership-source DOWN: unset source on pending join_requests'); +} diff --git a/modules/organizations/models/organizations.membership.model.mongoose.js b/modules/organizations/models/organizations.membership.model.mongoose.js index e1516e819..f6262bb74 100644 --- a/modules/organizations/models/organizations.membership.model.mongoose.js +++ b/modules/organizations/models/organizations.membership.model.mongoose.js @@ -2,6 +2,7 @@ * Module dependencies */ import mongoose from 'mongoose'; +import { MEMBERSHIP_STATUSES, PENDING_SOURCES } from '../lib/constants.js'; const Schema = mongoose.Schema; @@ -30,12 +31,43 @@ const MembershipMongoose = new Schema( enum: ['active', 'pending'], default: 'active', }, + // Consent discriminator for PENDING memberships. Intentionally NO default: + // a forgotten `source` on a PENDING row must FAIL (pre-validate guard below), + // never silently default to 'join_request' (which an owner could approve → + // consent bypass). See PENDING_SOURCES in lib/constants.js. + source: { + type: String, + enum: Object.values(PENDING_SOURCES), + }, + // Provenance: the owner/admin who created an owner_add invitation (audit only). + addedBy: { + type: Schema.ObjectId, + ref: 'User', + default: null, + }, }, { timestamps: true, }, ); +/** + * Consent guard: a PENDING membership MUST declare an explicit source. + * Without this, an owner_add that forgot to set `source` would be + * indistinguishable from a join_request and could be approved by the owner, + * bypassing the invited user's consent. Use the constant (status is stored + * lowercase as MEMBERSHIP_STATUSES.PENDING — a `=== 'PENDING'` check is inert). + * + * Implemented as a no-arg sync hook that throws: mongoose rejects the validate() + * promise on a thrown pre-hook error. (A `function(next)` signature is brittle here + * — mongoose's arity detection can invoke it with no `next` under doc.validate().) + */ +MembershipMongoose.pre('validate', function enforcePendingSource() { + if (this.status === MEMBERSHIP_STATUSES.PENDING && !this.source) { + throw new Error('PENDING membership requires explicit source'); + } +}); + /** * Compound unique index to prevent duplicate memberships */ diff --git a/modules/organizations/models/organizations.membership.schema.js b/modules/organizations/models/organizations.membership.schema.js index 5c39f25d3..7e68d1922 100644 --- a/modules/organizations/models/organizations.membership.schema.js +++ b/modules/organizations/models/organizations.membership.schema.js @@ -10,6 +10,17 @@ const MembershipUpdate = z.object({ role: z.enum(['owner', 'admin', 'member']), }); +/** + * Owner/admin add-member payload. `userId` is the target user's id (required — + * the user must already exist; resolve from an email via the /members/search + * lookup first). `role` is the role to grant on acceptance (defaults to member). + */ +const MembershipAdd = z.object({ + userId: z.string().min(1), + role: z.enum(['owner', 'admin', 'member']).optional(), +}); + export default { MembershipUpdate, + MembershipAdd, }; diff --git a/modules/organizations/routes/organizations.membership.routes.js b/modules/organizations/routes/organizations.membership.routes.js index 7b3fd7a0c..2ca2c0572 100644 --- a/modules/organizations/routes/organizations.membership.routes.js +++ b/modules/organizations/routes/organizations.membership.routes.js @@ -14,10 +14,21 @@ import membershipSchema from '../models/organizations.membership.schema.js'; */ export default (app) => { // Member routes (nested under organization) + // GET → list members. POST → owner/admin adds a user (creates a PENDING owner_add + // membership the invited user must accept). Both resolve to the `Membership` + // CASL subject via the `/members` path segment (policy.isAllowed). app .route('/api/organizations/:organizationId/members') .all(passport.authenticate('jwt', { session: false }), organizations.loadMembership, policy.isAllowed) - .get(members.list); + .get(members.list) + .post(model.isValid(membershipSchema.MembershipAdd), members.addMember); + + // User-directory lookup by EXACT email (owner/admin add-member affordance, P5b). + // Registered BEFORE /members/:memberId so 'search' is not captured as :memberId. + app + .route('/api/organizations/:organizationId/members/search') + .all(passport.authenticate('jwt', { session: false }), organizations.loadMembership, policy.isAllowed) + .get(members.findUserByEmail); app .route('/api/organizations/:organizationId/members/:memberId') diff --git a/modules/organizations/routes/organizations.membershipRequest.routes.js b/modules/organizations/routes/organizations.membershipRequest.routes.js index 80f6883a8..07450eab0 100644 --- a/modules/organizations/routes/organizations.membershipRequest.routes.js +++ b/modules/organizations/routes/organizations.membershipRequest.routes.js @@ -13,12 +13,29 @@ import membershipRequests from '../controllers/organizations.membershipRequest.c * @param {Object} app - Express application instance */ export default (app) => { - // User's own membership requests + // User's own pending OWNER_ADD invitations to accept (P5b "pending invitations" + // list). Auth-only: the invitee is not yet an org member, so NO org-membership / + // CASL check — and registered BEFORE /:membershipId so 'mine' isn't captured as one. + app + .route('/api/membership-requests/mine/pending') + .all(passport.authenticate('jwt', { session: false })) + .get(membershipRequests.listMinePending); + + // User's own membership JOIN requests (initiated by the user, awaiting approval) app .route('/api/membership-requests/mine') .all(passport.authenticate('jwt', { session: false })) .get(membershipRequests.listMine); + // The INVITED USER accepts a pending owner_add membership (consent activation). + // Auth-only — the consent gate (membership is a PENDING owner_add AND belongs to + // the caller) is enforced in the service, not via org-membership CASL. The owner + // can NEVER reach this for someone else's invite. + app + .route('/api/membership-requests/:membershipId/accept') + .all(passport.authenticate('jwt', { session: false })) + .put(membershipRequests.accept); + // Create a request to join an organization / list pending requests for an organization app .route('/api/organizations/:organizationId/requests') diff --git a/modules/organizations/services/organizations.membership.service.js b/modules/organizations/services/organizations.membership.service.js index 772e0dc4b..03d4f47b8 100644 --- a/modules/organizations/services/organizations.membership.service.js +++ b/modules/organizations/services/organizations.membership.service.js @@ -9,7 +9,31 @@ import { assertEmailVerified } from '../../../lib/helpers/emailVerification.js'; import MembershipRepository from '../repositories/organizations.membership.repository.js'; import OrganizationRepository from '../repositories/organizations.repository.js'; import UserService from '../../users/services/users.service.js'; -import { MEMBERSHIP_STATUSES, MEMBERSHIP_ROLES } from '../lib/constants.js'; +import { MEMBERSHIP_STATUSES, MEMBERSHIP_ROLES, PENDING_SOURCES } from '../lib/constants.js'; + +/** + * E17 — migration-ordering defensive read. + * + * Pre-existing PENDING rows (created before the `source` field existed) carry no + * `source` and were ALL join-requests. Until the backfill migration + * (`20260610140000-backfill-membership-source`) is confirmed run in every + * environment, the owner-approval surface must keep matching those legacy rows. + * So "this is a join-request the owner may approve" === source is join_request + * OR source is absent. + * + * REMOVE the `{ source: { $exists: false } }` branch once the backfill is + * confirmed run everywhere (follow-up — see MIGRATIONS.md). After that, every + * PENDING row has an explicit source (enforced by the model pre-validate guard). + * + * DOC-LEVEL TWIN: `isOwnerApprovable(source)` (organizations/lib/constants.js) is + * the document-level expression of the SAME consent invariant — a PENDING row is + * owner-approvable iff it is NOT an owner_add (join_request + legacy no-source). + * A Mongo filter can't reuse a predicate, so this `$or` stays; keep the two in + * lock-step (this filter selects the rows `isOwnerApprovable` returns true for). + */ +const joinRequestSourceFilter = { + $or: [{ source: PENDING_SOURCES.JOIN_REQUEST }, { source: { $exists: false } }], +}; /** * @function validateLastOwnerProtection @@ -124,19 +148,42 @@ const remove = async (membership) => { /** * @function listPending - * @description Service to retrieve pending join requests for an organization. + * @description Service to retrieve pending JOIN REQUESTS for an organization (the + * owner/admin approval surface). Scoped to `source: 'join_request'` so an + * owner_add (which awaits the INVITED user's consent, not the owner's approval) + * NEVER appears here — invariant #1. E17 fallback keeps pre-backfill legacy rows + * (no `source`) visible. * @param {String} organizationId - The ID of the organization. - * @returns {Promise} A promise that resolves to the list of pending memberships. + * @returns {Promise} A promise that resolves to the list of pending join requests. */ -const listPending = (organizationId) => MembershipRepository.list({ organizationId, status: MEMBERSHIP_STATUSES.PENDING }); +const listPending = (organizationId) => + MembershipRepository.list({ organizationId, status: MEMBERSHIP_STATUSES.PENDING, ...joinRequestSourceFilter }); /** * @function listPendingByUser - * @description Service to retrieve pending join requests for a user. + * @description Service to retrieve a user's own pending JOIN REQUESTS (the requests + * they initiated and are awaiting approval on). Scoped to `source: 'join_request'` + * (+ E17 legacy fallback) — owner_add invitations the user must ACCEPT are a + * distinct surface (see listPendingOwnerAddsByUser / the my-pending endpoint). + * Feeds the auth-payload `pendingRequests` ("your request to join X is pending"). * @param {String} userId - The ID of the user. - * @returns {Promise} A promise that resolves to the list of pending memberships. + * @returns {Promise} A promise that resolves to the list of pending join requests. */ -const listPendingByUser = (userId) => MembershipRepository.list({ userId, status: MEMBERSHIP_STATUSES.PENDING }); +const listPendingByUser = (userId) => + MembershipRepository.list({ userId, status: MEMBERSHIP_STATUSES.PENDING, ...joinRequestSourceFilter }); + +/** + * @function listPendingOwnerAddsByUser + * @description Service to retrieve a user's pending OWNER_ADD invitations — the + * memberships an owner/admin created for them that they must ACCEPT to activate. + * Powers the "pending invitations to accept" list (my-pending endpoint, P5b). + * Scoped to `source: 'owner_add'` exactly (no legacy fallback — pre-backfill rows + * were all join_requests, never owner_adds). + * @param {String} userId - The ID of the user. + * @returns {Promise} A promise resolving to the user's pending owner_add memberships. + */ +const listPendingOwnerAddsByUser = (userId) => + MembershipRepository.list({ userId, status: MEMBERSHIP_STATUSES.PENDING, source: PENDING_SOURCES.OWNER_ADD }); /** * @function createJoinRequest @@ -153,15 +200,21 @@ const createJoinRequest = async (userId, organizationId) => { if (!user) throw new Error('User not found'); assertEmailVerified(user); + // Per-(user,org) duplicate guard: ANY existing membership blocks a join request, + // regardless of source — you cannot request to join an org you already have an + // active membership in, a pending join_request for, or a pending owner_add invite to. const existing = await MembershipRepository.findOne({ userId, organizationId, status: { $in: [MEMBERSHIP_STATUSES.ACTIVE, MEMBERSHIP_STATUSES.PENDING] } }); if (existing) { if (existing.status === MEMBERSHIP_STATUSES.ACTIVE) throw new Error('Already a member of this organization'); + if (existing.source === PENDING_SOURCES.OWNER_ADD) throw new Error('You already have a pending invitation to this organization. Please accept or decline it.'); throw new Error('A pending request already exists'); } - // Limit to 1 pending request at a time across all organizations - const pendingAnywhere = await MembershipRepository.findOne({ userId, status: MEMBERSHIP_STATUSES.PENDING }); + // Limit to 1 pending JOIN REQUEST at a time across all organizations. Source-scoped + // (+ E17 legacy fallback): a pending owner_add invitation in another org must NOT + // block the user from requesting to join — the two surfaces are orthogonal. + const pendingAnywhere = await MembershipRepository.findOne({ userId, status: MEMBERSHIP_STATUSES.PENDING, ...joinRequestSourceFilter }); if (pendingAnywhere) throw new Error('You already have a pending request. Please wait for it to be reviewed before requesting to join another organization.'); - const membership = await MembershipRepository.create({ userId, organizationId, role: MEMBERSHIP_ROLES.MEMBER, status: MEMBERSHIP_STATUSES.PENDING }); + const membership = await MembershipRepository.create({ userId, organizationId, role: MEMBERSHIP_ROLES.MEMBER, status: MEMBERSHIP_STATUSES.PENDING, source: PENDING_SOURCES.JOIN_REQUEST }); if (mailer.isConfigured()) { const org = await OrganizationRepository.get(organizationId); @@ -257,6 +310,126 @@ const rejectRequest = async (membership) => { return MembershipRepository.remove(membership); }; +/** + * @function findUserByExactEmail + * @description Look up a single user by EXACT email for the owner/admin add-member + * affordance (P5b). "Exact" means exact-after-normalization: the lookup matches + * modulo `normalizeEmail` (lowercased + trimmed), which is correct — that is the + * form emails are stored under (the CI-unique-email index), so this is an exact + * match on the canonical value, NOT a byte-for-byte raw-string match. + * GDPR/privacy: exact-match only (no fuzzy name enumeration of the user + * directory). Returns minimal identity (id, displayName, email) for a match, or + * null. Authorization (owner/admin-only) is enforced at the controller. + * @param {String} email - The email to look up (matched lowercased/trimmed). + * @returns {Promise<{id: String, displayName: String, email: String}|null>} The matched user or null. + */ +const findUserByExactEmail = async (email) => { + const user = await UserService.findByEmail(email); + if (!user) return null; + return { + id: String(user._id || user.id), + displayName: [user.firstName, user.lastName].filter(Boolean).join(' '), + email: user.email, + }; +}; + +/** + * @function addMember + * @description Owner/admin adds a user to the organization. Creates a PENDING + * membership tagged `source: 'owner_add'`. CONSENT-CRITICAL: the membership does + * NOT activate here and NEVER appears in the owner's join-request approval list + * (invariant #1) — only the INVITED USER can activate it via acceptMembership. + * + * `status` is set EXPLICITLY to PENDING: the model defaults `status` to 'active', + * so omitting it would silently create an ACTIVE member without consent — a worse + * bypass than the approval-surface leak. The model pre-validate guard also rejects + * a PENDING row with no source. + * + * Rejects if ANY membership already exists for (user, org) — active, pending + * join_request, or pending owner_add — so an owner cannot double-add, stack an + * invite onto a member, or race a user's join request. + * @param {String} organizationId - The organization to add the user to. + * @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. + * @throws {Error} If userId is missing, the user does not exist, or a membership already exists. + */ +const addMember = async (organizationId, userId, role, addedBy) => { + if (!userId) throw new Error('A user is required to add a member'); + const user = await UserService.getBrut({ id: String(userId) }); + if (!user) throw new Error('User not found'); + + // Per-(user,org) guard across BOTH sources and both statuses — an owner_add must + // not duplicate, nor be stacked on an existing membership / pending join request. + const existing = await MembershipRepository.findOne({ + userId, + organizationId, + status: { $in: [MEMBERSHIP_STATUSES.ACTIVE, MEMBERSHIP_STATUSES.PENDING] }, + }); + if (existing) { + if (existing.status === MEMBERSHIP_STATUSES.ACTIVE) throw new Error('User is already a member of this organization'); + throw new Error('This user already has a pending membership for this organization'); + } + + // status EXPLICIT — never rely on the schema 'active' default (consent bypass). + return MembershipRepository.create({ + userId, + organizationId, + role: role || MEMBERSHIP_ROLES.MEMBER, + status: MEMBERSHIP_STATUSES.PENDING, + source: PENDING_SOURCES.OWNER_ADD, + addedBy, + }); +}; + +/** + * @function acceptMembership + * @description The INVITED USER accepts a pending owner_add membership, flipping it + * PENDING → ACTIVE. CONSENT-CRITICAL — this is the ONLY path that activates an + * owner_add, and it activates ONLY for the invited user: + * - the membership must be PENDING and `source === 'owner_add'` (a join_request + * is approved by the owner via approveRequest, NOT accepted here); + * - `membership.userId` must equal `acceptingUserId` — the owner (or anyone + * else) accepting on the invitee's behalf is rejected. + * Returns null on any consent-mismatch so the controller can answer 403/404 + * without leaking which condition failed. On success, sets the user's + * currentOrganization if they have none (mirrors approveRequest). + * @param {String} membershipId - The pending owner_add membership to accept. + * @param {String} acceptingUserId - The authenticated user accepting (must be the invitee). + * @returns {Promise} The activated membership, or null if not acceptable by this user. + */ +const acceptMembership = async (membershipId, acceptingUserId) => { + // Self-defending consent gate: an absent caller can never own a membership, and + // `String(undefined) === String(membership.userId?._id || undefined)` could + // sentinel-collide. Reject up-front so the gate never leans on the route being + // auth-only (null → 404, matching the not-found / not-eligible path). + if (!acceptingUserId) return null; + const membership = await MembershipRepository.get(membershipId); + if (!membership) return null; + + // Consent gate. Compare the membership owner to the caller; an owner_add can ONLY + // be accepted by its invited user. A join_request, an already-active membership, + // or another user's invite are all rejected (null → 404/403 at the controller). + 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(acceptingUserId)) return null; + + membership.status = MEMBERSHIP_STATUSES.ACTIVE; + const result = await MembershipRepository.update(membership); + + // Set currentOrganization if the accepting user doesn't have one (parity with approveRequest). + const userDoc = await UserService.getBrut({ id: String(acceptingUserId) }); + if (userDoc && !userDoc.currentOrganization) { + await UserService.updateById(userDoc._id, { + currentOrganization: membership.organizationId._id || membership.organizationId, + }); + } + + return result; +}; + /** * @function leave * @description Leave an organization. Prevents the last owner from leaving. @@ -325,9 +498,13 @@ export default { leave, listPending, listPendingByUser, + listPendingOwnerAddsByUser, createJoinRequest, approveRequest, rejectRequest, + findUserByExactEmail, + addMember, + acceptMembership, count, aggregateCountByOrganizations, deleteMany, diff --git a/modules/organizations/tests/organizations.backfillMembershipSource.migration.integration.tests.js b/modules/organizations/tests/organizations.backfillMembershipSource.migration.integration.tests.js new file mode 100644 index 000000000..94232bbeb --- /dev/null +++ b/modules/organizations/tests/organizations.backfillMembershipSource.migration.integration.tests.js @@ -0,0 +1,90 @@ +/** + * Module dependencies. + */ +import mongoose from 'mongoose'; + +import { beforeAll, afterAll, describe, test, expect } from '@jest/globals'; +import { bootstrap } from '../../../lib/app.js'; +import mongooseService from '../../../lib/services/mongoose.js'; +import { PENDING_SOURCES } from '../lib/constants.js'; + +import { up } from '../migrations/20260610140000-backfill-membership-source.js'; + +/** + * Migration `20260610140000-backfill-membership-source` (P5a — #3813). + * + * Sets `source: 'join_request'` on all pre-existing PENDING memberships (they were + * all join requests before the owner_add flow existed). Verified via the RAW + * collection driver. Must: + * - backfill source on a sourceless PENDING row; + * - leave ACTIVE rows untouched (source only matters while PENDING); + * - NOT overwrite a row that already has a source; + * - be idempotent (a second run changes nothing). + */ +describe('Migration backfill-membership-source:', () => { + let memberships; + const orgId = new mongoose.Types.ObjectId(); + const userPending = new mongoose.Types.ObjectId(); + const userActive = new mongoose.Types.ObjectId(); + const pendingId = new mongoose.Types.ObjectId(); + const activeId = new mongoose.Types.ObjectId(); + const alreadySourcedId = new mongoose.Types.ObjectId(); + + beforeAll(async () => { + await bootstrap(); + memberships = mongoose.connection.db.collection('memberships'); + await memberships.deleteMany({ _id: { $in: [pendingId, activeId, alreadySourcedId] } }); + + // (1) Legacy PENDING row with NO source — the backfill target. + await memberships.insertOne({ + _id: pendingId, userId: userPending, organizationId: orgId, role: 'member', status: 'pending', + createdAt: new Date(), updatedAt: new Date(), + }); + // (2) ACTIVE row with no source — must stay untouched. + await memberships.insertOne({ + _id: activeId, userId: userActive, organizationId: orgId, role: 'owner', status: 'active', + createdAt: new Date(), updatedAt: new Date(), + }); + // (3) A PENDING row that already has a source — must NOT be overwritten. + await memberships.insertOne({ + _id: alreadySourcedId, userId: new mongoose.Types.ObjectId(), organizationId: orgId, role: 'member', + status: 'pending', source: PENDING_SOURCES.OWNER_ADD, createdAt: new Date(), updatedAt: new Date(), + }); + + await up(); + }); + + afterAll(async () => { + try { + await memberships.deleteMany({ _id: { $in: [pendingId, activeId, alreadySourcedId] } }); + } catch (_) { /* cleanup */ } + try { + await mongooseService.disconnect(); + } catch (e) { + console.log(e); + expect(e).toBeFalsy(); + } + }); + + test('backfills source=join_request on the sourceless PENDING row', async () => { + const doc = await memberships.findOne({ _id: pendingId }); + expect(doc.source).toBe(PENDING_SOURCES.JOIN_REQUEST); + }); + + test('leaves the ACTIVE row untouched (no source added)', async () => { + const doc = await memberships.findOne({ _id: activeId }); + expect(doc).not.toHaveProperty('source'); + }); + + test('does NOT overwrite a row that already has a source', async () => { + const doc = await memberships.findOne({ _id: alreadySourcedId }); + expect(doc.source).toBe(PENDING_SOURCES.OWNER_ADD); + }); + + test('is idempotent — a second run leaves everything as-is', async () => { + await up(); + expect((await memberships.findOne({ _id: pendingId })).source).toBe(PENDING_SOURCES.JOIN_REQUEST); + expect(await memberships.findOne({ _id: activeId })).not.toHaveProperty('source'); + expect((await memberships.findOne({ _id: alreadySourcedId })).source).toBe(PENDING_SOURCES.OWNER_ADD); + }); +}); diff --git a/modules/organizations/tests/organizations.membership.consent.unit.tests.js b/modules/organizations/tests/organizations.membership.consent.unit.tests.js new file mode 100644 index 000000000..bdd7150a2 --- /dev/null +++ b/modules/organizations/tests/organizations.membership.consent.unit.tests.js @@ -0,0 +1,336 @@ +/** + * Module dependencies. + */ +import { jest, describe, test, expect, beforeEach } from '@jest/globals'; + +import { MEMBERSHIP_STATUSES, MEMBERSHIP_ROLES, PENDING_SOURCES } from '../lib/constants.js'; + +/** + * Consent unit tests for the membership service (P5a — #3813). + * + * Covers the two HARD INVARIANTS of the owner-add consent split: + * 1. an owner_add membership is PENDING + source:'owner_add' and is activated + * ONLY by the invited user via acceptMembership — never by the owner; + * 2. status/source comparisons use the lowercase constants, and a fresh + * owner-add is NEVER active. + * Plus the source-scoping of createJoinRequest's single-pending rule (both + * directions) and the exact-email user lookup. + */ + +const mockFindOne = jest.fn(); +const mockCreate = jest.fn(); +const mockUpdate = jest.fn(); +const mockGet = jest.fn(); +const mockList = jest.fn(); +const mockCount = jest.fn(); +jest.unstable_mockModule('../repositories/organizations.membership.repository.js', () => ({ + default: { + findOne: mockFindOne, + create: mockCreate, + update: mockUpdate, + get: mockGet, + list: mockList, + count: mockCount, + }, +})); + +const mockOrgGet = jest.fn(); +jest.unstable_mockModule('../repositories/organizations.repository.js', () => ({ + default: { get: mockOrgGet }, +})); + +const mockGetBrut = jest.fn(); +const mockUpdateById = jest.fn().mockResolvedValue({}); +const mockFindByEmail = jest.fn(); +jest.unstable_mockModule('../../users/services/users.service.js', () => ({ + default: { getBrut: mockGetBrut, updateById: mockUpdateById, findByEmail: mockFindByEmail }, +})); + +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 consent service unit tests:', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetBrut.mockResolvedValue({ _id: USER }); + mockCreate.mockImplementation((data) => Promise.resolve({ _id: 'm-new', ...data })); + }); + + describe('addMember', () => { + test('creates a PENDING owner_add membership (status set EXPLICITLY, never the active default)', async () => { + mockFindOne.mockResolvedValue(null); + + const result = await MembershipService.addMember(ORG, USER, MEMBERSHIP_ROLES.MEMBER, OWNER); + + expect(mockCreate).toHaveBeenCalledTimes(1); + const payload = mockCreate.mock.calls[0][0]; + // Invariant #2: status is explicitly PENDING (the schema would otherwise default to 'active'). + expect(payload.status).toBe(MEMBERSHIP_STATUSES.PENDING); + expect(payload.status).not.toBe(MEMBERSHIP_STATUSES.ACTIVE); + expect(payload.source).toBe(PENDING_SOURCES.OWNER_ADD); + expect(payload.userId).toBe(USER); + expect(payload.organizationId).toBe(ORG); + expect(payload.addedBy).toBe(OWNER); + // A fresh owner-add is NEVER active. + expect(result.status).toBe(MEMBERSHIP_STATUSES.PENDING); + }); + + test('defaults role to member when none is given', async () => { + mockFindOne.mockResolvedValue(null); + await MembershipService.addMember(ORG, USER, undefined, OWNER); + expect(mockCreate.mock.calls[0][0].role).toBe(MEMBERSHIP_ROLES.MEMBER); + }); + + test('rejects when the user is already an ACTIVE member', async () => { + mockFindOne.mockResolvedValue({ _id: 'm1', status: MEMBERSHIP_STATUSES.ACTIVE }); + await expect(MembershipService.addMember(ORG, USER, MEMBERSHIP_ROLES.MEMBER, OWNER)) + .rejects.toThrow('already a member'); + expect(mockCreate).not.toHaveBeenCalled(); + }); + + test('rejects when ANY pending membership already exists (no double-add / no stacking)', async () => { + mockFindOne.mockResolvedValue({ _id: 'm1', status: MEMBERSHIP_STATUSES.PENDING, source: PENDING_SOURCES.JOIN_REQUEST }); + await expect(MembershipService.addMember(ORG, USER, MEMBERSHIP_ROLES.MEMBER, OWNER)) + .rejects.toThrow('already has a pending membership'); + expect(mockCreate).not.toHaveBeenCalled(); + }); + + test('the duplicate-check is per-(user, org) across BOTH statuses', async () => { + mockFindOne.mockResolvedValue(null); + await MembershipService.addMember(ORG, USER, MEMBERSHIP_ROLES.MEMBER, OWNER); + const filter = mockFindOne.mock.calls[0][0]; + expect(filter.userId).toBe(USER); + expect(filter.organizationId).toBe(ORG); + expect(filter.status.$in).toEqual(expect.arrayContaining([MEMBERSHIP_STATUSES.ACTIVE, MEMBERSHIP_STATUSES.PENDING])); + }); + + test('rejects when no userId is supplied', async () => { + await expect(MembershipService.addMember(ORG, undefined, MEMBERSHIP_ROLES.MEMBER, OWNER)) + .rejects.toThrow('user is required'); + expect(mockCreate).not.toHaveBeenCalled(); + }); + + test('rejects when the target user does not exist', async () => { + mockGetBrut.mockResolvedValue(null); + await expect(MembershipService.addMember(ORG, USER, MEMBERSHIP_ROLES.MEMBER, OWNER)) + .rejects.toThrow('User not found'); + expect(mockCreate).not.toHaveBeenCalled(); + }); + }); + + describe('acceptMembership — consent gate', () => { + /** + * @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, + save: jest.fn(), + ...overrides, + }); + + test('the INVITED USER can accept their own pending owner_add → flips to ACTIVE', async () => { + const membership = ownerAddPending(); + mockGet.mockResolvedValue(membership); + mockUpdate.mockImplementation((m) => Promise.resolve(m)); + mockGetBrut.mockResolvedValue({ _id: USER, currentOrganization: null }); + + const result = await MembershipService.acceptMembership('m1', USER); + + expect(result).not.toBeNull(); + expect(membership.status).toBe(MEMBERSHIP_STATUSES.ACTIVE); + expect(mockUpdate).toHaveBeenCalledTimes(1); + }); + + test('CONSENT BYPASS GUARD: the OWNER (a different user) CANNOT accept on the invitee behalf → null, no activation', async () => { + const membership = ownerAddPending(); + mockGet.mockResolvedValue(membership); + + const result = await MembershipService.acceptMembership('m1', OWNER); + + expect(result).toBeNull(); + expect(membership.status).toBe(MEMBERSHIP_STATUSES.PENDING); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + test('a JOIN REQUEST cannot be accepted via acceptMembership (only approveRequest activates it) → null', async () => { + const membership = ownerAddPending({ source: PENDING_SOURCES.JOIN_REQUEST }); + mockGet.mockResolvedValue(membership); + + const result = await MembershipService.acceptMembership('m1', USER); + + expect(result).toBeNull(); + expect(membership.status).toBe(MEMBERSHIP_STATUSES.PENDING); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + test('an already-ACTIVE membership is not re-accepted → null', async () => { + const membership = ownerAddPending({ status: MEMBERSHIP_STATUSES.ACTIVE }); + mockGet.mockResolvedValue(membership); + + const result = await MembershipService.acceptMembership('m1', USER); + + expect(result).toBeNull(); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + test('an unknown membership id → null', async () => { + mockGet.mockResolvedValue(null); + const result = await MembershipService.acceptMembership('missing', USER); + expect(result).toBeNull(); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + test('SELF-DEFENDING GATE: an undefined acceptingUserId → null, no activation (gate does not lean on the auth-only route)', async () => { + // A valid pending owner_add exists, but the caller id is absent. The early + // guard must reject BEFORE the String(undefined) identity compare could + // sentinel-collide — proving the gate self-defends regardless of the route. + const membership = ownerAddPending(); + mockGet.mockResolvedValue(membership); + + const result = await MembershipService.acceptMembership('m1', undefined); + + expect(result).toBeNull(); + expect(membership.status).toBe(MEMBERSHIP_STATUSES.PENDING); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + test('matches the invited user even when userId is a populated sub-doc', async () => { + const membership = ownerAddPending({ userId: { _id: USER, email: 'a@b.com' } }); + mockGet.mockResolvedValue(membership); + mockUpdate.mockImplementation((m) => Promise.resolve(m)); + mockGetBrut.mockResolvedValue({ _id: USER, currentOrganization: ORG }); + + const result = await MembershipService.acceptMembership('m1', USER); + expect(result).not.toBeNull(); + expect(membership.status).toBe(MEMBERSHIP_STATUSES.ACTIVE); + }); + + test('sets currentOrganization when the accepting user has none (parity with approveRequest)', async () => { + const membership = ownerAddPending(); + mockGet.mockResolvedValue(membership); + mockUpdate.mockImplementation((m) => Promise.resolve(m)); + mockGetBrut.mockResolvedValue({ _id: USER, currentOrganization: null }); + + await MembershipService.acceptMembership('m1', USER); + expect(mockUpdateById).toHaveBeenCalledWith(USER, { currentOrganization: ORG }); + }); + + test('does NOT overwrite an existing currentOrganization', async () => { + const membership = ownerAddPending(); + mockGet.mockResolvedValue(membership); + mockUpdate.mockImplementation((m) => Promise.resolve(m)); + mockGetBrut.mockResolvedValue({ _id: USER, currentOrganization: 'other-org' }); + + await MembershipService.acceptMembership('m1', USER); + expect(mockUpdateById).not.toHaveBeenCalled(); + }); + }); + + describe('createJoinRequest single-pending rule — source scoping (both directions)', () => { + beforeEach(() => { + // First findOne (per-org duplicate) returns null → fall through to single-pending check. + mockFindOne.mockResolvedValueOnce(null); + mockGetBrut.mockResolvedValue({ _id: USER, email: 'u@x.com', emailVerified: true }); + }); + + test('a pending OWNER_ADD does NOT block creating a join request (owner_add ⊥ join_request)', async () => { + // The single-pending query is source-scoped to join_request, so it must find nothing. + mockFindOne.mockResolvedValueOnce(null); + + await MembershipService.createJoinRequest(USER, ORG); + + // The single-pending guard query carried a join_request source scope. + const singlePendingFilter = mockFindOne.mock.calls[1][0]; + expect(singlePendingFilter.status).toBe(MEMBERSHIP_STATUSES.PENDING); + expect(JSON.stringify(singlePendingFilter)).toContain(PENDING_SOURCES.JOIN_REQUEST); + // It created the join request (tagged join_request). + expect(mockCreate).toHaveBeenCalledTimes(1); + expect(mockCreate.mock.calls[0][0].source).toBe(PENDING_SOURCES.JOIN_REQUEST); + }); + + test('an existing pending JOIN REQUEST in another org DOES block a new join request', async () => { + mockFindOne.mockResolvedValueOnce({ _id: 'jr-elsewhere', status: MEMBERSHIP_STATUSES.PENDING, source: PENDING_SOURCES.JOIN_REQUEST }); + await expect(MembershipService.createJoinRequest(USER, ORG)).rejects.toThrow('already have a pending request'); + expect(mockCreate).not.toHaveBeenCalled(); + }); + }); + + describe('createJoinRequest per-(user,org) duplicate guard', () => { + test('a pending OWNER_ADD for the SAME org blocks a join request with an invitation-specific message', async () => { + mockGetBrut.mockResolvedValue({ _id: USER, email: 'u@x.com', emailVerified: true }); + mockFindOne.mockResolvedValueOnce({ _id: 'inv', status: MEMBERSHIP_STATUSES.PENDING, source: PENDING_SOURCES.OWNER_ADD }); + await expect(MembershipService.createJoinRequest(USER, ORG)).rejects.toThrow('pending invitation'); + expect(mockCreate).not.toHaveBeenCalled(); + }); + }); + + describe('source-scoped pending listings (E16 / invariant #1)', () => { + test('listPending (owner approval surface) filters to join_request with an E17 legacy fallback', async () => { + mockList.mockResolvedValue([]); + await MembershipService.listPending(ORG); + const filter = mockList.mock.calls[0][0]; + expect(filter.status).toBe(MEMBERSHIP_STATUSES.PENDING); + // join_request OR no source (pre-backfill legacy rows stay visible). + expect(filter.$or).toEqual(expect.arrayContaining([ + { source: PENDING_SOURCES.JOIN_REQUEST }, + { source: { $exists: false } }, + ])); + }); + + test('listPendingByUser (auth-payload pendingRequests) is the same join_request scope', async () => { + mockList.mockResolvedValue([]); + await MembershipService.listPendingByUser(USER); + const filter = mockList.mock.calls[0][0]; + expect(filter.userId).toBe(USER); + expect(filter.$or).toBeDefined(); + }); + + test('listPendingOwnerAddsByUser (my-pending invitations) filters to owner_add exactly (no legacy fallback)', async () => { + mockList.mockResolvedValue([]); + await MembershipService.listPendingOwnerAddsByUser(USER); + const filter = mockList.mock.calls[0][0]; + expect(filter.status).toBe(MEMBERSHIP_STATUSES.PENDING); + expect(filter.source).toBe(PENDING_SOURCES.OWNER_ADD); + expect(filter.$or).toBeUndefined(); + }); + }); + + describe('findUserByExactEmail', () => { + test('returns minimal identity for a matched user', async () => { + mockFindByEmail.mockResolvedValue({ _id: 'u9', firstName: 'Ada', lastName: 'Lovelace', email: 'ada@x.com' }); + const result = await MembershipService.findUserByExactEmail('ada@x.com'); + expect(result).toEqual({ id: 'u9', displayName: 'Ada Lovelace', email: 'ada@x.com' }); + }); + + test('returns null when no user matches', async () => { + mockFindByEmail.mockResolvedValue(null); + const result = await MembershipService.findUserByExactEmail('none@x.com'); + expect(result).toBeNull(); + }); + }); +}); diff --git a/modules/organizations/tests/organizations.membership.controller.unit.tests.js b/modules/organizations/tests/organizations.membership.controller.unit.tests.js index 2b3a7fa40..fc895d0a3 100644 --- a/modules/organizations/tests/organizations.membership.controller.unit.tests.js +++ b/modules/organizations/tests/organizations.membership.controller.unit.tests.js @@ -8,6 +8,8 @@ const mockList = jest.fn(); const mockUpdateRole = jest.fn(); const mockRemove = jest.fn(); const mockGet = jest.fn(); +const mockAddMember = jest.fn(); +const mockFindUserByExactEmail = jest.fn(); jest.unstable_mockModule('../services/organizations.membership.service.js', () => ({ default: { @@ -15,6 +17,8 @@ jest.unstable_mockModule('../services/organizations.membership.service.js', () = updateRole: mockUpdateRole, remove: mockRemove, get: mockGet, + addMember: mockAddMember, + findUserByExactEmail: mockFindUserByExactEmail, }, })); @@ -223,4 +227,109 @@ describe('Membership controller unit tests:', () => { expect(res.status).not.toHaveBeenCalledWith(403); }); }); + + describe('addMember', () => { + test('owner can add a plain member → delegates to the service', async () => { + mockAddMember.mockResolvedValue({ id: 'mNew', status: 'pending', source: 'owner_add' }); + const req = mockReq({ membership: { role: MEMBERSHIP_ROLES.OWNER }, body: { userId: 'u9' } }); + const res = mockRes(); + + await membershipController.addMember(req, res); + + expect(mockAddMember).toHaveBeenCalledTimes(1); + // (organizationId, userId, role, addedBy) + expect(mockAddMember).toHaveBeenCalledWith('org1', 'u9', MEMBERSHIP_ROLES.MEMBER, 'u1'); + expect(res.status).not.toHaveBeenCalledWith(403); + }); + + test('admin can add a plain member', async () => { + mockAddMember.mockResolvedValue({ id: 'mNew' }); + const req = mockReq({ membership: { role: MEMBERSHIP_ROLES.ADMIN }, body: { userId: 'u9' } }); + const res = mockRes(); + + await membershipController.addMember(req, res); + + expect(mockAddMember).toHaveBeenCalledTimes(1); + expect(res.status).not.toHaveBeenCalledWith(403); + }); + + test('admin CANNOT add an elevated role (owner/admin) → 403, service not called', async () => { + const req = mockReq({ membership: { role: MEMBERSHIP_ROLES.ADMIN }, body: { userId: 'u9', role: MEMBERSHIP_ROLES.ADMIN } }); + const res = mockRes(); + + await membershipController.addMember(req, res); + + expect(res.status).toHaveBeenCalledWith(403); + expect(mockAddMember).not.toHaveBeenCalled(); + }); + + test('owner CAN add an elevated role', async () => { + mockAddMember.mockResolvedValue({ id: 'mNew' }); + const req = mockReq({ membership: { role: MEMBERSHIP_ROLES.OWNER }, body: { userId: 'u9', role: MEMBERSHIP_ROLES.OWNER } }); + const res = mockRes(); + + await membershipController.addMember(req, res); + + expect(mockAddMember).toHaveBeenCalledWith('org1', 'u9', MEMBERSHIP_ROLES.OWNER, 'u1'); + expect(res.status).not.toHaveBeenCalledWith(403); + }); + + test('global admin can add an elevated role even with no org membership', async () => { + mockAddMember.mockResolvedValue({ id: 'mNew' }); + const req = mockReq({ + user: { _id: 'adm', roles: ['admin'] }, + membership: undefined, + body: { userId: 'u9', role: MEMBERSHIP_ROLES.ADMIN }, + }); + const res = mockRes(); + + await membershipController.addMember(req, res); + + expect(mockAddMember).toHaveBeenCalledTimes(1); + expect(res.status).not.toHaveBeenCalledWith(403); + }); + + test('a service rejection surfaces as 422', async () => { + mockAddMember.mockRejectedValue(new Error('User is already a member of this organization')); + const req = mockReq({ membership: { role: MEMBERSHIP_ROLES.OWNER }, body: { userId: 'u9' } }); + const res = mockRes(); + + await membershipController.addMember(req, res); + + expect(res.status).toHaveBeenCalledWith(422); + }); + }); + + describe('findUserByEmail', () => { + test('owner gets a matched user', async () => { + mockFindUserByExactEmail.mockResolvedValue({ id: 'u9', displayName: 'Ada Lovelace', email: 'ada@x.com' }); + const req = mockReq({ membership: { role: MEMBERSHIP_ROLES.OWNER }, query: { email: 'ada@x.com' } }); + const res = mockRes(); + + await membershipController.findUserByEmail(req, res); + + expect(mockFindUserByExactEmail).toHaveBeenCalledWith('ada@x.com'); + expect(res.status).not.toHaveBeenCalledWith(403); + }); + + test('a plain MEMBER cannot enumerate users → 403 (CASL read is too broad for this surface)', async () => { + const req = mockReq({ membership: { role: MEMBERSHIP_ROLES.MEMBER }, query: { email: 'ada@x.com' } }); + const res = mockRes(); + + await membershipController.findUserByEmail(req, res); + + expect(res.status).toHaveBeenCalledWith(403); + expect(mockFindUserByExactEmail).not.toHaveBeenCalled(); + }); + + test('missing email query → 422', async () => { + const req = mockReq({ membership: { role: MEMBERSHIP_ROLES.OWNER }, query: {} }); + const res = mockRes(); + + await membershipController.findUserByEmail(req, res); + + expect(res.status).toHaveBeenCalledWith(422); + expect(mockFindUserByExactEmail).not.toHaveBeenCalled(); + }); + }); }); diff --git a/modules/organizations/tests/organizations.membership.model.consent.integration.tests.js b/modules/organizations/tests/organizations.membership.model.consent.integration.tests.js new file mode 100644 index 000000000..2a35beb79 --- /dev/null +++ b/modules/organizations/tests/organizations.membership.model.consent.integration.tests.js @@ -0,0 +1,84 @@ +/** + * Module dependencies. + */ +import mongoose from 'mongoose'; + +import { beforeAll, afterAll, describe, test, expect } from '@jest/globals'; +import { bootstrap } from '../../../lib/app.js'; +import mongooseService from '../../../lib/services/mongoose.js'; +import { MEMBERSHIP_STATUSES, PENDING_SOURCES } from '../lib/constants.js'; + +/** + * Model-level consent guard (P5a — #3813). + * + * The Membership model's pre('validate') hook MUST reject a PENDING membership that + * carries no `source` — without it, an owner_add that forgot to set source would be + * indistinguishable from a join_request and could be approved by the owner (consent + * bypass). These tests run real mongoose validation (no DB write) against the model + * registered at bootstrap, so a reverted guard fails them. + */ +describe('Membership model consent guard (pre-validate source):', () => { + let Membership; + const orgId = new mongoose.Types.ObjectId(); + const userId = new mongoose.Types.ObjectId(); + + beforeAll(async () => { + await bootstrap(); + Membership = mongoose.model('Membership'); + }); + + afterAll(async () => { + try { + await mongooseService.disconnect(); + } catch (e) { + console.log(e); + expect(e).toBeFalsy(); + } + }); + + test('REJECTS a PENDING membership with no source (the consent guard)', async () => { + const doc = new Membership({ userId, organizationId: orgId, status: MEMBERSHIP_STATUSES.PENDING }); + await expect(doc.validate()).rejects.toThrow('PENDING membership requires explicit source'); + }); + + test('ACCEPTS a PENDING owner_add membership', async () => { + const doc = new Membership({ + userId, + organizationId: orgId, + status: MEMBERSHIP_STATUSES.PENDING, + source: PENDING_SOURCES.OWNER_ADD, + }); + await expect(doc.validate()).resolves.toBeUndefined(); + }); + + test('ACCEPTS a PENDING join_request membership', async () => { + const doc = new Membership({ + userId, + organizationId: orgId, + status: MEMBERSHIP_STATUSES.PENDING, + source: PENDING_SOURCES.JOIN_REQUEST, + }); + await expect(doc.validate()).resolves.toBeUndefined(); + }); + + test('ACCEPTS an ACTIVE membership with no source (source only matters while PENDING)', async () => { + const doc = new Membership({ userId, organizationId: orgId, status: MEMBERSHIP_STATUSES.ACTIVE }); + await expect(doc.validate()).resolves.toBeUndefined(); + }); + + test('the default status is active — so an add MUST set PENDING explicitly', () => { + // A bare membership defaults to active (the reason addMember sets status explicitly). + const doc = new Membership({ userId, organizationId: orgId }); + expect(doc.status).toBe(MEMBERSHIP_STATUSES.ACTIVE); + }); + + test('REJECTS an unknown source value (enum guard)', async () => { + const doc = new Membership({ + userId, + organizationId: orgId, + status: MEMBERSHIP_STATUSES.PENDING, + source: 'not_a_real_source', + }); + await expect(doc.validate()).rejects.toThrow(); + }); +}); diff --git a/modules/organizations/tests/organizations.membershipRequest.controller.unit.tests.js b/modules/organizations/tests/organizations.membershipRequest.controller.unit.tests.js new file mode 100644 index 000000000..cb26dcd6e --- /dev/null +++ b/modules/organizations/tests/organizations.membershipRequest.controller.unit.tests.js @@ -0,0 +1,137 @@ +/** + * Module dependencies. + */ +import { jest, describe, test, expect, beforeEach } from '@jest/globals'; + +import { MEMBERSHIP_STATUSES, PENDING_SOURCES } from '../lib/constants.js'; + +/** + * Unit tests for the membershipRequest controller — consent-critical surfaces (P5a, #3813): + * - requestByID (the owner approve/reject gate) must be INVISIBLE to owner_adds; + * - accept lets the invited user activate their owner_add (consent in the service); + * - listMinePending surfaces the user's owner_add invitations. + */ + +const mockGet = jest.fn(); +const mockAcceptMembership = jest.fn(); +const mockListPendingOwnerAddsByUser = jest.fn(); +const mockListPendingByUser = jest.fn(); + +jest.unstable_mockModule('../services/organizations.membership.service.js', () => ({ + default: { + get: mockGet, + acceptMembership: mockAcceptMembership, + listPendingOwnerAddsByUser: mockListPendingOwnerAddsByUser, + listPendingByUser: mockListPendingByUser, + listPending: jest.fn(), + createJoinRequest: jest.fn(), + approveRequest: jest.fn(), + rejectRequest: jest.fn(), + }, +})); + +const { default: controller } = await import('../controllers/organizations.membershipRequest.controller.js'); + +/** + * @desc Minimal Express-like res with spies. + * @returns {Object} mock response + */ +function mockRes() { + const res = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + return res; +} + +describe('membershipRequest controller — consent surfaces:', () => { + beforeEach(() => jest.clearAllMocks()); + + describe('requestByID (owner approve/reject gate)', () => { + const ORG = 'org1'; + const next = jest.fn(); + + test('CONSENT INVARIANT #1: an owner_add PENDING is INVISIBLE here → 404 (owner can never approve it)', async () => { + mockGet.mockResolvedValue({ + _id: 'm1', organizationId: ORG, status: MEMBERSHIP_STATUSES.PENDING, source: PENDING_SOURCES.OWNER_ADD, + }); + const req = { organization: { _id: ORG } }; + const res = mockRes(); + + await controller.requestByID(req, res, next, 'm1'); + + expect(res.status).toHaveBeenCalledWith(404); + expect(next).not.toHaveBeenCalled(); + expect(req.membershipRequest).toBeUndefined(); + }); + + test('a join_request PENDING in the org is visible → next() with req.membershipRequest set', async () => { + const doc = { _id: 'm2', organizationId: ORG, status: MEMBERSHIP_STATUSES.PENDING, source: PENDING_SOURCES.JOIN_REQUEST }; + mockGet.mockResolvedValue(doc); + const req = { organization: { _id: ORG } }; + const res = mockRes(); + + await controller.requestByID(req, res, next, 'm2'); + + expect(next).toHaveBeenCalledTimes(1); + expect(req.membershipRequest).toBe(doc); + }); + + test('E17: a legacy PENDING with NO source stays visible (pre-backfill rows are join requests)', async () => { + const doc = { _id: 'm3', organizationId: ORG, status: MEMBERSHIP_STATUSES.PENDING }; + mockGet.mockResolvedValue(doc); + const req = { organization: { _id: ORG } }; + const res = mockRes(); + + await controller.requestByID(req, res, next, 'm3'); + + expect(next).toHaveBeenCalledTimes(1); + expect(req.membershipRequest).toBe(doc); + }); + + test('a request for another org → 404', async () => { + mockGet.mockResolvedValue({ _id: 'm4', organizationId: 'other', status: MEMBERSHIP_STATUSES.PENDING, source: PENDING_SOURCES.JOIN_REQUEST }); + const req = { organization: { _id: ORG } }; + const res = mockRes(); + + await controller.requestByID(req, res, next, 'm4'); + expect(res.status).toHaveBeenCalledWith(404); + expect(next).not.toHaveBeenCalled(); + }); + }); + + describe('accept (invited user activates their owner_add)', () => { + test('passes the membershipId + the AUTHENTICATED user to the service', async () => { + mockAcceptMembership.mockResolvedValue({ id: 'm1', status: MEMBERSHIP_STATUSES.ACTIVE }); + const req = { params: { membershipId: 'm1' }, user: { _id: 'invitee' } }; + const res = mockRes(); + + await controller.accept(req, res); + + expect(mockAcceptMembership).toHaveBeenCalledWith('m1', 'invitee'); + expect(res.status).not.toHaveBeenCalledWith(404); + }); + + test('a null service result (consent mismatch / not found) → 404, never leaks which', async () => { + mockAcceptMembership.mockResolvedValue(null); + const req = { params: { membershipId: 'm1' }, user: { _id: 'someone-else' } }; + const res = mockRes(); + + await controller.accept(req, res); + + expect(res.status).toHaveBeenCalledWith(404); + }); + }); + + describe('listMinePending (user owner_add invitations)', () => { + test('returns the user pending owner_add invitations', async () => { + mockListPendingOwnerAddsByUser.mockResolvedValue([{ id: 'inv1' }]); + const req = { user: { _id: 'u1' } }; + const res = mockRes(); + + await controller.listMinePending(req, res); + + expect(mockListPendingOwnerAddsByUser).toHaveBeenCalledWith('u1'); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ data: [{ id: 'inv1' }] })); + }); + }); +});