diff --git a/MIGRATIONS.md b/MIGRATIONS.md index c83385844..e4a6ca4a9 100644 --- a/MIGRATIONS.md +++ b/MIGRATIONS.md @@ -4,6 +4,27 @@ Breaking changes and upgrade notes for downstream projects. --- +## 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). + +### What changed (this repo) + +- **Routes removed** (now 404): `POST /api/organizations/:organizationId/invites`, `GET /api/invites/:token`, `POST /api/invites/:token/accept`. Org **join-requests** (`/requests`, `/requests/:id/approve`, `/requests/:id/reject`, `/membership-requests/mine`) and member CRUD are **unchanged**. +- **Service/controller** (`organizations.membership.service.js`, `organizations.membershipRequest.{controller,routes}.js`) — `invite` / `acceptInvite` / `getInvite` deleted. +- **Membership model** (`organizations.membership.model.mongoose.js`) — dropped the `inviteToken` / `invitedEmail` / `inviteExpiresAt` fields, removed `'invited'` (and the never-written `'rejected'`) from the `status` enum, and removed the sparse `inviteToken_1` index. The partial-unique `(userId, organizationId)`, the `organizationId`, and the `(organizationId, status)` indexes are kept. +- **Constants** — `MEMBERSHIP_STATUSES` now `{ ACTIVE, PENDING }` (`INVITED` and the dead `REJECTED` removed; `rejectRequest` hard-deletes the doc, so `rejected` was never written). +- **Template** `config/templates/org-invite.html` deleted. +- **Migration** `modules/organizations/migrations/20260610130000-drop-org-invited-memberships.js`: deletes leftover `status:'invited'` memberships (often `userId:null` orphans), unsets the removed invite fields on any survivor, and drops the `inviteToken_1` index (idempotent; absent index swallowed). + +### Action required for downstream projects (`/update-project`) + +1. The module/model/migration changes are devkit-owned → arrive via `/update-stack` (`--theirs`). +2. The migration runs at boot before `listen()` and removes any leftover org `invited` memberships + drops the index automatically. (Trawl has ~1 such test row → handled in epic Phase 9 / #3815.) +3. No platform-invitation (`sign.cap` / `?inviteToken=` signup gate) behavior changes. Any downstream UI calling the removed `/invites` org routes must migrate to the add-member flow (#3813 / Vue #4280). + +--- + ## Invitations hardening: case-insensitive unique email index + two-phase invite claim (2026-06-10) Phase 3 of the invitations↔org decouple epic (#3811). Two downstream-relevant changes. @@ -757,9 +778,11 @@ None for Node (CASL `@casl/ability` was already installed). No new npm packages | `GET` | `/api/admin/organizations/:organizationId` | JWT+Admin| Platform admin: get org | | `DELETE` | `/api/admin/organizations/:organizationId` | JWT+Admin| Platform admin: delete org | | `GET` | `/api/organizations/:organizationId/members` | JWT | List members | -| `POST` | `/api/organizations/:organizationId/members/invite` | JWT | Invite a member | | `PUT` | `/api/organizations/:organizationId/members/:memberId` | JWT | Update member role | | `DELETE` | `/api/organizations/:organizationId/members/:memberId` | JWT | Remove member | +| `POST` | `/api/organizations/:organizationId/requests` | JWT | Request to join | +| `PUT` | `/api/organizations/:organizationId/requests/:membershipRequestId/approve` | JWT | Approve join request | +| `PUT` | `/api/organizations/:organizationId/requests/:membershipRequestId/reject` | JWT | Reject join request | --- @@ -1061,7 +1084,7 @@ The organizations module follows the standard Devkit module structure: modules/organizations/ controllers/ organizations.controller.js # CRUD + adminList + organizationByID param middleware - organizations.membership.controller.js # list, invite, updateRole, remove + memberByID + organizations.membership.controller.js # list, updateRole, remove + memberByID helpers/ slug.js # slugify() + generateOrganizationSlug() migrations/ @@ -1070,7 +1093,7 @@ modules/organizations/ organizations.model.mongoose.js # Organization Mongoose model (name, slug, domain, plan, createdBy) organizations.schema.js # Zod validation schema organizations.membership.model.mongoose.js # Membership Mongoose model (userId, organizationId, role) - organizations.membership.schema.js # Zod validation (MembershipInvite, MembershipUpdate) + organizations.membership.schema.js # Zod validation (MembershipUpdate) policies/ organizations.policy.js # CASL abilities for Organization + Membership subjects repositories/ diff --git a/README.md b/README.md index 21b7351c3..a2af2ab13 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Designed to be cloned into downstream projects and kept up-to-date via `git merg - **User** : classic register / auth or oAuth (Google, Apple) - profile management (update, avatar upload) - **User data privacy** : delete all - get all - send all by mail - **Admin** : list users - get user - edit user - delete user -- **Organizations** : multi-tenant organization management - create, update, delete orgs - member invite, role management (owner/admin/member) - platform admin org listing +- **Organizations** : multi-tenant organization management - create, update, delete orgs - join requests (request / approve / reject), role management (owner/admin/member) - platform admin org listing - **Signup access control** : invite-only signup (single-use token links) + hard account cap (beta gating) - admin-managed invitations, public signup auto-locks at the cap - **CASL v2 Authorization** : document-level permission checks via [@casl/ability](https://casl.js.org/) - replaces route-level role rules with per-document conditions (ownership, org scope) - **Migration System** : automatic database migrations at boot - tracks executed scripts in MongoDB - idempotent reruns @@ -189,9 +189,12 @@ Both file types are optional and can be used independently or together. Per-modu | `PUT` | `/api/organizations/:organizationId` | JWT | Update organization | | `DELETE` | `/api/organizations/:organizationId` | JWT | Delete organization | | `GET` | `/api/organizations/:organizationId/members` | JWT | List members | -| `POST` | `/api/organizations/:organizationId/members/invite` | JWT | Invite member | | `PUT` | `/api/organizations/:organizationId/members/:memberId` | JWT | Update member role | | `DELETE` | `/api/organizations/:organizationId/members/:memberId` | JWT | Remove member | +| `POST` | `/api/organizations/:organizationId/requests` | JWT | Request to join | +| `GET` | `/api/organizations/:organizationId/requests` | JWT | List pending join requests | +| `PUT` | `/api/organizations/:organizationId/requests/:membershipRequestId/approve` | JWT | Approve join request | +| `PUT` | `/api/organizations/:organizationId/requests/:membershipRequestId/reject` | JWT | Reject join request | | `GET` | `/api/admin/organizations` | JWT+Admin | List all organizations | | `GET` | `/api/admin/organizations/:organizationId` | JWT+Admin | Get any organization | | `DELETE` | `/api/admin/organizations/:organizationId` | JWT+Admin | Delete any organization | diff --git a/config/templates/org-invite.html b/config/templates/org-invite.html deleted file mode 100644 index e7ac549c5..000000000 --- a/config/templates/org-invite.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - -

Hello,

-

{{inviterName}} has invited you to join {{orgName}} on {{appName}}.

-

Click here to accept the invitation: {{url}}

-

The {{appName}} Team.

-
- If you weren't expecting this invitation, you can ignore this email. - Contact us here. - - diff --git a/modules/audit/middlewares/audit.middleware.js b/modules/audit/middlewares/audit.middleware.js index 046da5c00..122a85bfd 100644 --- a/modules/audit/middlewares/audit.middleware.js +++ b/modules/audit/middlewares/audit.middleware.js @@ -21,7 +21,7 @@ const OBJECT_ID_RE = /^[0-9a-fA-F]{24}$/; /** * Derive a human-readable action from the Express route path. * e.g. `/api/auth/signin` → `auth.signin` - * `/api/organizations/:orgId/members/invite` → `organizations.invite` + * `/api/organizations/:orgId/members/:memberId` → `organizations.members` * `/api/billing/checkout` → `billing.checkout` * @param {string} routePath - The Express matched route path (req.route.path) * @param {string} baseUrl - The Express baseUrl (req.baseUrl) diff --git a/modules/audit/tests/audit.middleware.unit.tests.js b/modules/audit/tests/audit.middleware.unit.tests.js index da95566ae..ccd8a2644 100644 --- a/modules/audit/tests/audit.middleware.unit.tests.js +++ b/modules/audit/tests/audit.middleware.unit.tests.js @@ -222,7 +222,7 @@ describe('Audit middleware unit tests:', () => { test('should derive action from nested route path', () => { expect(deriveAction('/signin', '/api/auth')).toBe('auth.signin'); expect(deriveAction('/checkout', '/api/billing')).toBe('billing.checkout'); - expect(deriveAction('/:orgId/members/invite', '/api/organizations')).toBe('organizations.invite'); + expect(deriveAction('/:orgId/members/:memberId', '/api/organizations')).toBe('organizations.members'); expect(deriveAction('/:token', '/api/auth/password/reset')).toBe('auth.reset'); }); diff --git a/modules/organizations/controllers/organizations.membershipRequest.controller.js b/modules/organizations/controllers/organizations.membershipRequest.controller.js index 53f57329e..8fdb57eb2 100644 --- a/modules/organizations/controllers/organizations.membershipRequest.controller.js +++ b/modules/organizations/controllers/organizations.membershipRequest.controller.js @@ -95,63 +95,6 @@ const listMine = async (req, res) => { } }; -/** - * @function invite - * @description Endpoint to invite a user to an organization by email. - * @param {Object} req - Express request object - * @param {Object} res - Express response object - * @returns {Promise} - */ -const invite = async (req, res) => { - try { - const { email } = req.body; - if (!email) return responses.error(res, 422, 'Unprocessable Entity', 'Email is required')(); - const result = await MembershipService.invite( - req.organization._id || req.organization.id, - email, - req.user, - ); - responses.success(res, 'invitation sent')(result); - } catch (err) { - responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err); - } -}; - -/** - * @function acceptInvite - * @description Endpoint to accept an organization invite by token. - * @param {Object} req - Express request object - * @param {Object} res - Express response object - * @returns {Promise} - */ -const acceptInvite = async (req, res) => { - try { - const { token } = req.params; - const membership = await MembershipService.acceptInvite(token, req.user._id || req.user.id); - responses.success(res, 'invitation accepted')(membership); - } catch (err) { - responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err); - } -}; - -/** - * @function getInvite - * @description Endpoint to get invite details by token. - * @param {Object} req - Express request object - * @param {Object} res - Express response object - * @returns {Promise} - */ -const getInvite = async (req, res) => { - try { - const { token } = req.params; - const membership = await MembershipService.getInvite(token); - if (!membership) return responses.error(res, 404, 'Not Found', 'Invalid or expired invite')(); - responses.success(res, 'invite details')(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. @@ -182,8 +125,5 @@ export default { approve, reject, listMine, - invite, - acceptInvite, - getInvite, requestByID, }; diff --git a/modules/organizations/lib/constants.js b/modules/organizations/lib/constants.js index a65a1c6e8..3110eb593 100644 --- a/modules/organizations/lib/constants.js +++ b/modules/organizations/lib/constants.js @@ -1,8 +1,6 @@ export const MEMBERSHIP_STATUSES = { ACTIVE: 'active', PENDING: 'pending', - INVITED: 'invited', - REJECTED: 'rejected', }; export const MEMBERSHIP_ROLES = { diff --git a/modules/organizations/migrations/20260610130000-drop-org-invited-memberships.js b/modules/organizations/migrations/20260610130000-drop-org-invited-memberships.js new file mode 100644 index 000000000..eba03e927 --- /dev/null +++ b/modules/organizations/migrations/20260610130000-drop-org-invited-memberships.js @@ -0,0 +1,72 @@ +/** + * Module dependencies + */ +import mongoose from 'mongoose'; + +const INVITE_TOKEN_INDEX = 'inviteToken_1'; + +/** + * Migration: Remove the organization email-invite feature's leftover data. + * + * The org-owned email-invite flow (`status:'invited'` memberships carrying an + * `inviteToken` / `invitedEmail` / `inviteExpiresAt`) has been deleted. This + * migration cleans up any residual data: + * - deletes leftover `invited` memberships (often `userId:null` orphans that + * would otherwise dangle with no corresponding user); + * - unsets the now-removed invite fields on any surviving membership; + * - drops the sparse `inviteToken_1` index that backed invite lookups. + * + * Uses the RAW collection driver (not the Mongoose model) for both writes: the + * schema no longer declares `inviteToken` / `invitedEmail` / `inviteExpiresAt`, + * so a model-based `updateMany` would have strict-mode strip those keys from the + * `$unset` operator — the driver would receive no `$unset` and the legacy fields + * would survive forever (the migration would record as executed having changed + * nothing). Same gotcha the billing migrations document (see + * `20260502100000-*` and `20260503000200-slim-subscription-drop-period-fields`). + * + * Idempotent: re-running is a no-op (no `invited` rows / no invite fields / the + * index already absent). + * @returns {Promise} Resolves when the cleanup has completed. + */ +export async function up() { + // Raw collection driver so neither the deleteMany filter nor the $unset is + // stripped by strict-mode schema enforcement (the model no longer declares + // the invite fields). Model `Membership` → default collection `memberships`. + const db = mongoose.connection.db; + const memberships = db.collection('memberships'); + + // Org email-invite removed; leftover INVITED rows (often userId:null) become orphans → delete. + await memberships.deleteMany({ status: 'invited' }); + + await memberships.updateMany( + { $or: [{ inviteToken: { $exists: true } }, { invitedEmail: { $exists: true } }, { inviteExpiresAt: { $exists: true } }] }, + { $unset: { inviteToken: '', invitedEmail: '', inviteExpiresAt: '' } }, + ); + + // Check-then-act so absence is the expected path (not an error to swallow) and + // either outcome is observable. listIndexes throws NamespaceNotFound on a fresh + // DB with no memberships collection — treat that as "nothing to drop". + let indexes = []; + try { + indexes = await memberships.indexes(); + } catch (err) { + if (err?.codeName === 'NamespaceNotFound' || err?.code === 26) { + console.info('[migration] drop-org-invited-memberships: memberships collection does not exist yet — nothing to drop'); + return; + } + throw err; + } + + if (indexes.some((ix) => ix.name === INVITE_TOKEN_INDEX)) { + await memberships.dropIndex(INVITE_TOKEN_INDEX); + console.info('[migration] drop-org-invited-memberships: dropped legacy inviteToken_1 index'); + } else { + console.info('[migration] drop-org-invited-memberships: inviteToken_1 index already absent — skipping drop'); + } +} + +/** + * No-op down migration. The dropped data/index cannot be meaningfully restored. + * @returns {void} + */ +export function down() { console.warn('[migration] drop-org-invited-memberships DOWN: no-op'); } diff --git a/modules/organizations/models/organizations.membership.model.mongoose.js b/modules/organizations/models/organizations.membership.model.mongoose.js index a05db458b..e1516e819 100644 --- a/modules/organizations/models/organizations.membership.model.mongoose.js +++ b/modules/organizations/models/organizations.membership.model.mongoose.js @@ -27,12 +27,9 @@ const MembershipMongoose = new Schema( }, status: { type: String, - enum: ['active', 'pending', 'rejected', 'invited'], + enum: ['active', 'pending'], default: 'active', }, - inviteToken: { type: String, default: null }, - invitedEmail: { type: String, default: null }, - inviteExpiresAt: { type: Date, default: null }, }, { timestamps: true, @@ -47,11 +44,6 @@ MembershipMongoose.index( { unique: true, partialFilterExpression: { userId: { $exists: true, $ne: null } } }, ); -/** - * Sparse index on inviteToken for invite lookups - */ -MembershipMongoose.index({ inviteToken: 1 }, { sparse: true }); - /** * Single-field index on organizationId for list-by-org queries */ diff --git a/modules/organizations/routes/organizations.membershipRequest.routes.js b/modules/organizations/routes/organizations.membershipRequest.routes.js index dc7868f26..80f6883a8 100644 --- a/modules/organizations/routes/organizations.membershipRequest.routes.js +++ b/modules/organizations/routes/organizations.membershipRequest.routes.js @@ -38,24 +38,6 @@ export default (app) => { .all(passport.authenticate('jwt', { session: false }), organizations.loadMembership, policy.isAllowed) .put(membershipRequests.reject); - // Invite a user to an organization (owner/admin) - app - .route('/api/organizations/:organizationId/invites') - .all(passport.authenticate('jwt', { session: false }), organizations.loadMembership, policy.isAllowed) - .post(membershipRequests.invite); - - // Get invite details - app - .route('/api/invites/:token') - .all(passport.authenticate('jwt', { session: false })) - .get(membershipRequests.getInvite); - - // Accept an invite - app - .route('/api/invites/:token/accept') - .all(passport.authenticate('jwt', { session: false })) - .post(membershipRequests.acceptInvite); - // Bind param middleware app.param('membershipRequestId', membershipRequests.requestByID); }; diff --git a/modules/organizations/services/organizations.membership.service.js b/modules/organizations/services/organizations.membership.service.js index e7b4ecb6e..772e0dc4b 100644 --- a/modules/organizations/services/organizations.membership.service.js +++ b/modules/organizations/services/organizations.membership.service.js @@ -1,8 +1,6 @@ /** * Module dependencies */ -import crypto from 'crypto'; - import config from '../../../config/index.js'; import logger from '../../../lib/services/logger.js'; import getBaseUrl from '../../../lib/helpers/getBaseUrl.js'; @@ -283,120 +281,6 @@ const leave = async (userId, organizationId) => { return { success: true }; }; -/** - * @function invite - * @description Invite a user to an organization by email. Creates an invited membership with a token. - * @param {String} organizationId - The ID of the organization. - * @param {String} email - The email address to invite. - * @param {Object} invitedBy - The user object of the inviter. - * @returns {Promise} The created membership and invite token. - */ -const invite = async (organizationId, email, invitedBy) => { - // Check for existing invited membership by email first (handles non-registered users) - const existingInvite = await MembershipRepository.findOne({ - invitedEmail: email.toLowerCase(), - organizationId, - status: MEMBERSHIP_STATUSES.INVITED, - }); - if (existingInvite) throw new Error('An invite has already been sent to this email'); - - const existingUser = await UserService.findByEmail(email); - if (existingUser) { - const existingMembership = await MembershipRepository.findOne({ - userId: existingUser._id, - organizationId, - status: { $in: [MEMBERSHIP_STATUSES.ACTIVE, MEMBERSHIP_STATUSES.PENDING, MEMBERSHIP_STATUSES.INVITED] }, - }); - if (existingMembership) throw new Error('User is already a member or has a pending request'); - } - - const inviteToken = crypto.randomBytes(20).toString('hex'); - const membership = await MembershipRepository.create({ - userId: existingUser ? existingUser._id : null, - organizationId, - role: MEMBERSHIP_ROLES.MEMBER, - status: MEMBERSHIP_STATUSES.INVITED, - inviteToken, - invitedEmail: email.toLowerCase(), - inviteExpiresAt: new Date(Date.now() + 7 * 24 * 3600000), - }); - - if (mailer.isConfigured()) { - const org = await OrganizationRepository.get(organizationId); - if (org?.name) { - try { - await mailer.sendMail({ - to: email, - subject: `You've been invited to join ${org.name}`, - template: 'org-invite', - params: { - inviterName: [invitedBy.firstName, invitedBy.lastName].filter(Boolean).join(' '), - orgName: org.name, - url: `${getBaseUrl()}/invite?token=${inviteToken}`, - appName: config.app.title, - appContact: config.mailer.from, - }, - }); - } catch { - // Clean up persisted membership so the invite can be retried - await MembershipRepository.remove(membership); - throw new Error('Failed to send invite email'); - } - } - } - - return { membership, inviteToken }; -}; - -/** - * @function acceptInvite - * @description Accept an organization invite by token. Sets the membership to active. - * @param {String} token - The invite token. - * @param {String} userId - The ID of the accepting user. - * @returns {Promise} The updated membership. - */ -const acceptInvite = async (token, userId) => { - const membership = await MembershipRepository.findOne({ inviteToken: token, status: MEMBERSHIP_STATUSES.INVITED }); - if (!membership) throw new Error('Invalid or expired invite'); - - if (membership.inviteExpiresAt && membership.inviteExpiresAt < Date.now()) { - throw new Error('Invite has expired'); - } - - // Verify the accepting user matches the intended invite recipient - const user = await UserService.getBrut({ id: String(userId) }); - if (!user) throw new Error('User not found'); - - const invitedUserId = membership.userId?._id || membership.userId; - if (invitedUserId && String(invitedUserId) !== String(userId)) { - throw new Error('This invite belongs to another user'); - } - if (membership.invitedEmail && membership.invitedEmail.toLowerCase() !== user.email.toLowerCase()) { - throw new Error('This invite belongs to another email address'); - } - - membership.userId = userId; - membership.status = MEMBERSHIP_STATUSES.ACTIVE; - membership.inviteToken = null; - const result = await MembershipRepository.update(membership); - - if (user && !user.currentOrganization) { - await UserService.updateById(user._id, { - currentOrganization: membership.organizationId._id || membership.organizationId, - }); - } - - return result; -}; - -/** - * @function getInvite - * @description Get invite details by token. - * @param {String} token - The invite token. - * @returns {Promise} The invited membership or null. - */ -const getInvite = (token) => MembershipRepository.findOne({ inviteToken: token, status: MEMBERSHIP_STATUSES.INVITED }); - /** * @function count * @description Service to count memberships matching a filter. @@ -444,9 +328,6 @@ export default { createJoinRequest, approveRequest, rejectRequest, - invite, - acceptInvite, - getInvite, count, aggregateCountByOrganizations, deleteMany, diff --git a/modules/organizations/tests/organizations.controller.unit.tests.js b/modules/organizations/tests/organizations.controller.unit.tests.js index 779eadcd8..59c7925e8 100644 --- a/modules/organizations/tests/organizations.controller.unit.tests.js +++ b/modules/organizations/tests/organizations.controller.unit.tests.js @@ -5,6 +5,7 @@ import { jest, describe, test, expect, beforeEach } from '@jest/globals'; const mockCrudRemove = jest.fn(); const mockListByUser = jest.fn(); +const mockLeave = jest.fn(); jest.unstable_mockModule('../services/organizations.crud.service.js', () => ({ default: { @@ -15,6 +16,7 @@ jest.unstable_mockModule('../services/organizations.crud.service.js', () => ({ jest.unstable_mockModule('../services/organizations.membership.service.js', () => ({ default: { listByUser: mockListByUser, + leave: mockLeave, }, })); @@ -117,4 +119,39 @@ describe('Organizations controller unit tests:', () => { expect(res.status).not.toHaveBeenCalledWith(422); }); }); + + describe('leave', () => { + test('should call MembershipService.leave with user and org ids and return success', async () => { + mockLeave.mockResolvedValue({ success: true }); + const req = mockReq(); + const res = mockRes(); + + await organizationsController.leave(req, res); + + expect(mockLeave).toHaveBeenCalledWith('u1', 'org1'); + expect(res.status).not.toHaveBeenCalledWith(422); + }); + + test('should return 422 when the last owner tries to leave', async () => { + mockLeave.mockRejectedValue(new Error('Cannot remove the last owner')); + const req = mockReq(); + const res = mockRes(); + + await organizationsController.leave(req, res); + + expect(mockLeave).toHaveBeenCalledTimes(1); + expect(res.status).toHaveBeenCalledWith(422); + }); + + test('should return 422 when the user is not a member', async () => { + mockLeave.mockRejectedValue(new Error('You are not a member of this organization')); + const req = mockReq(); + const res = mockRes(); + + await organizationsController.leave(req, res); + + expect(mockLeave).toHaveBeenCalledTimes(1); + expect(res.status).toHaveBeenCalledWith(422); + }); + }); }); diff --git a/modules/organizations/tests/organizations.dropInvitedMemberships.migration.integration.tests.js b/modules/organizations/tests/organizations.dropInvitedMemberships.migration.integration.tests.js new file mode 100644 index 000000000..89f4150cd --- /dev/null +++ b/modules/organizations/tests/organizations.dropInvitedMemberships.migration.integration.tests.js @@ -0,0 +1,113 @@ +/** + * 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 { up } from '../migrations/20260610130000-drop-org-invited-memberships.js'; + +/** + * Regression: `20260610130000-drop-org-invited-memberships` must unset the legacy + * invite fields via the RAW collection driver. + * + * The Membership schema no longer declares `inviteToken` / `invitedEmail` / + * `inviteExpiresAt`. A model-based `Membership.updateMany(..., { $unset })` would + * have strict mode strip those keys from the update operator → the driver receives + * no `$unset` and the fields survive (the bug this test guards). The migration + * therefore writes via `db.collection('memberships')`. + * + * Crucial: docs are inserted AND read back via the raw collection. A model-based + * read would project schema-less fields to `undefined` regardless of whether the + * physical document still carries them — it would hide the bug. Reading raw is the + * only way this test fails when the fix is reverted to the model-based `$unset`. + */ +describe('Migration drop-org-invited-memberships (raw $unset regression):', () => { + let memberships; + const orgId = new mongoose.Types.ObjectId(); + const userId = new mongoose.Types.ObjectId(); + // Stable ids so we can target our own fixtures for read-back + cleanup. + const survivingId = new mongoose.Types.ObjectId(); + const invitedId = new mongoose.Types.ObjectId(); + + beforeAll(async () => { + await bootstrap(); + memberships = mongoose.connection.db.collection('memberships'); + + // Clean any prior fixtures (idempotent across local re-runs). + await memberships.deleteMany({ _id: { $in: [survivingId, invitedId] } }); + + // (1) A SURVIVING active membership that still physically carries the three + // legacy invite fields — inserted raw to bypass schema stripping. + await memberships.insertOne({ + _id: survivingId, + userId, + organizationId: orgId, + role: 'member', + status: 'active', + inviteToken: 'legacy-token-abc123', + invitedEmail: 'legacy@example.com', + inviteExpiresAt: new Date('2026-01-01T00:00:00.000Z'), + createdAt: new Date(), + updatedAt: new Date(), + }); + + // (2) A leftover `status:'invited'` orphan row that the migration must DELETE. + await memberships.insertOne({ + _id: invitedId, + userId: null, + organizationId: orgId, + role: 'member', + status: 'invited', + inviteToken: 'orphan-token-xyz789', + invitedEmail: 'orphan@example.com', + inviteExpiresAt: new Date('2026-01-01T00:00:00.000Z'), + createdAt: new Date(), + updatedAt: new Date(), + }); + + // Run the migration under test. + await up(); + }); + + afterAll(async () => { + try { + await memberships.deleteMany({ _id: { $in: [survivingId, invitedId] } }); + } catch (_) { /* cleanup */ } + try { + await mongooseService.disconnect(); + } catch (e) { + console.log(e); + expect(e).toBeFalsy(); + } + }); + + test('unsets inviteToken / invitedEmail / inviteExpiresAt on the surviving row (read RAW)', async () => { + // Read via the raw collection — a model read would mask schema-less fields. + const doc = await memberships.findOne({ _id: survivingId }); + + expect(doc).not.toBeNull(); + // The row itself survives (it was active, not invited). + expect(doc.status).toBe('active'); + // The three legacy fields are physically gone from the document. + expect(doc).not.toHaveProperty('inviteToken'); + expect(doc).not.toHaveProperty('invitedEmail'); + expect(doc).not.toHaveProperty('inviteExpiresAt'); + }); + + test("deletes the leftover status:'invited' row", async () => { + const orphan = await memberships.findOne({ _id: invitedId }); + expect(orphan).toBeNull(); + }); + + test('is idempotent — a second run is a no-op and leaves the surviving row clean', async () => { + await up(); + const doc = await memberships.findOne({ _id: survivingId }); + expect(doc).not.toBeNull(); + expect(doc).not.toHaveProperty('inviteToken'); + expect(doc).not.toHaveProperty('invitedEmail'); + expect(doc).not.toHaveProperty('inviteExpiresAt'); + }); +}); diff --git a/modules/organizations/tests/organizations.lifecycle.e2e.tests.js b/modules/organizations/tests/organizations.lifecycle.e2e.tests.js deleted file mode 100644 index e1147d3ca..000000000 --- a/modules/organizations/tests/organizations.lifecycle.e2e.tests.js +++ /dev/null @@ -1,214 +0,0 @@ -/** - * @desc E2E tests for the full organization lifecycle flow. - * Tests: signup → invite → accept → promote → leave. - */ -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 lifecycle 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 invite lifecycle', () => { - let userA; - let userB; - let orgA; - let agentA; - let agentB; - - afterAll(async () => { - // Clean up remaining data - try { - if (orgA) { - await MembershipRepository.deleteMany({ organizationId: orgA._id }); - await OrganizationsRepository.deleteMany({ _id: orgA._id }); - } - } catch (_) { /* cleanup */ } - // Clean up user B's auto-created org - await cleanupUser(userB); - await cleanupUser(userA); - }); - - test('should complete the full invite lifecycle: signup → invite → accept → promote → leave', async () => { - config.organizations = { enabled: true, autoCreate: true, domainMatching: false }; - agentA = request.agent(agent.app); - agentB = request.agent(agent.app); - - // Step 1: Signup user A (creates org automatically) - try { - const resultA = await agentA - .post('/api/auth/signup') - .send({ - firstName: 'LifecycleA', - lastName: 'User', - email: 'lifecycle-a@test.com', - password: 'W@os.jsI$Aw3$0m3', - provider: 'local', - }) - .expect(200); - - userA = resultA.body.user; - orgA = resultA.body.organization; - expect(orgA).toBeDefined(); - expect(orgA).not.toBeNull(); - } catch (err) { - console.log(err); - expect(err).toBeFalsy(); - } - - // Step 2: User A invites user B by email - let inviteToken; - try { - const inviteResult = await agentA - .post(`/api/organizations/${orgA._id}/invites`) - .send({ email: 'lifecycle-b@test.com' }) - .expect(200); - - expect(inviteResult.body.message).toBe('invitation sent'); - inviteToken = inviteResult.body.data.inviteToken; - expect(inviteToken).toBeDefined(); - } catch (err) { - console.log(err); - expect(err).toBeFalsy(); - } - - // Step 3: Signup user B - try { - const resultB = await agentB - .post('/api/auth/signup') - .send({ - firstName: 'LifecycleB', - lastName: 'User', - email: 'lifecycle-b@test.com', - password: 'W@os.jsI$Aw3$0m3', - provider: 'local', - }) - .expect(200); - - userB = resultB.body.user; - } catch (err) { - console.log(err); - expect(err).toBeFalsy(); - } - - // Step 4: User B accepts the invite - try { - const acceptResult = await agentB - .post(`/api/invites/${inviteToken}/accept`) - .expect(200); - - expect(acceptResult.body.message).toBe('invitation accepted'); - } catch (err) { - console.log(err); - expect(err).toBeFalsy(); - } - - // Step 5: Verify user B is now active member - try { - const membersResult = await agentA - .get(`/api/organizations/${orgA._id}/members`) - .expect(200); - - const memberB = membersResult.body.data.find( - (m) => m.userId && m.userId.email === 'lifecycle-b@test.com', - ); - expect(memberB).toBeDefined(); - expect(memberB.status).toBe('active'); - expect(memberB.role).toBe('member'); - - // Step 6: User A promotes user B to owner - const promoteResult = await agentA - .put(`/api/organizations/${orgA._id}/members/${memberB._id}`) - .send({ role: 'owner' }) - .expect(200); - - expect(promoteResult.body.data.role).toBe('owner'); - } catch (err) { - console.log(err); - expect(err).toBeFalsy(); - } - - // Step 7: User A leaves the org - try { - const leaveResult = await agentA - .post(`/api/organizations/${orgA._id}/leave`) - .expect(200); - - expect(leaveResult.body.message).toBe('organization left'); - } catch (err) { - console.log(err); - expect(err).toBeFalsy(); - } - - // Step 8: Verify user A membership is deleted - try { - const membershipA = await MembershipRepository.findOne({ - userId: userA.id, - organizationId: orgA._id, - status: 'active', - }); - expect(membershipA).toBeNull(); - } catch (err) { - console.log(err); - expect(err).toBeFalsy(); - } - }); - }); - - // Mongoose disconnect - afterAll(async () => { - resetOrgConfig(); - try { - await mongooseService.disconnect(); - } catch (err) { - console.log(err); - expect(err).toBeFalsy(); - } - }); -});