From 3b0a1c98ab8ae18c6bc35b6e75adec3596eaa088 Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Sun, 12 Oct 2025 21:20:24 -0500 Subject: [PATCH 1/5] skip external group updates for ACM officers and infra chairs. Those are managed out of band for security --- src/api/routes/organizations.ts | 23 +++++++++++++++-------- src/common/overrides.ts | 8 ++++++++ 2 files changed, 23 insertions(+), 8 deletions(-) create mode 100644 src/common/overrides.ts diff --git a/src/api/routes/organizations.ts b/src/api/routes/organizations.ts index 03e22e80..ff706969 100644 --- a/src/api/routes/organizations.ts +++ b/src/api/routes/organizations.ts @@ -61,6 +61,7 @@ import { assignIdpGroupsToTeam, createGithubTeam, } from "api/functions/github.js"; +import { SKIP_EXTERNAL_ORG_LEAD_UPDATE } from "common/overrides.js"; export const CLIENT_HTTP_CACHE_POLICY = `public, max-age=${ORG_DATA_CACHED_DURATION}, stale-while-revalidate=${Math.floor(ORG_DATA_CACHED_DURATION * 1.1)}, stale-if-error=3600`; @@ -349,6 +350,7 @@ const organizationsPlugin: FastifyPluginAsync = async (fastify, _options) => { }, }, async (request, reply) => { + const orgId = getOrgByName(request.params.orgId)!.id; const { add, remove } = request.body; const allUsernames = [...add.map((u) => u.username), ...remove]; const officersEmail = @@ -405,6 +407,9 @@ const organizationsPlugin: FastifyPluginAsync = async (fastify, _options) => { fastify.dynamoClient.send(getMetadataCommand), getAuthorizedClients(), ]); + // Metadata has been updated. If they are in the skip set, skip them. + const shouldSkipEnhancedActions = + SKIP_EXTERNAL_ORG_LEAD_UPDATE.includes(orgId); let entraGroupId = metadataResponse.Item ? (unmarshall(metadataResponse.Item).leadsEntraGroupId as string) : undefined; @@ -422,7 +427,10 @@ const organizationsPlugin: FastifyPluginAsync = async (fastify, _options) => { logger: request.log, }); - const shouldCreateNewEntraGroup = !entraGroupId; + const shouldCreateNewEntraGroup = + !entraGroupId && !shouldSkipEnhancedActions; + const shouldCreateNewGithubGroup = + !githubTeamId && !shouldSkipEnhancedActions; const grpDisplayName = `${request.params.orgId} Admin`; const orgInfo = getOrgByName(request.params.orgId); if (!orgInfo) { @@ -518,7 +526,7 @@ const organizationsPlugin: FastifyPluginAsync = async (fastify, _options) => { } // Create GitHub team if needed - if (!githubTeamId) { + if (shouldCreateNewGithubGroup) { request.log.info( `No GitHub team exists for ${request.params.orgId}. Creating new team...`, ); @@ -576,7 +584,6 @@ const organizationsPlugin: FastifyPluginAsync = async (fastify, _options) => { `Store GitHub team ID for ${request.params.orgId}`, ); } - const commonArgs = { orgId: request.params.orgId, actorUsername: request.username!, @@ -635,7 +642,11 @@ const organizationsPlugin: FastifyPluginAsync = async (fastify, _options) => { }); } - if (createdGithubTeam && fastify.environmentConfig.GithubIdpSyncEnabled) { + if ( + createdGithubTeam && + githubTeamId && + fastify.environmentConfig.GithubIdpSyncEnabled + ) { request.log.info("Setting up IDP sync for Github team!"); await assignIdpGroupsToTeam({ githubToken: fastify.secretConfig.github_pat, @@ -645,10 +656,6 @@ const organizationsPlugin: FastifyPluginAsync = async (fastify, _options) => { orgId: fastify.environmentConfig.GithubOrgId, orgName: fastify.environmentConfig.GithubOrgName, }); - } else { - request.log.warn( - "IdP sync is disabled in this environment - the newly created group will have no members!", - ); } return reply.status(201).send(); diff --git a/src/common/overrides.ts b/src/common/overrides.ts new file mode 100644 index 00000000..1a5b0846 --- /dev/null +++ b/src/common/overrides.ts @@ -0,0 +1,8 @@ +import { OrganizationId } from "@acm-uiuc/js-shared"; + +/** + * Skip creating/updating external Entra Groups for these org's leads + * These org's leads are managed directly in Entra ID due to their sensitive nature. + * We only perform the metadata update in DynamoDB for these orgs. + */ +export const SKIP_EXTERNAL_ORG_LEAD_UPDATE: OrganizationId[] = ["A01", "C01"] From 286f8cd7de22db1907edc96a34feaef777309c34 Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Sun, 12 Oct 2025 21:22:29 -0500 Subject: [PATCH 2/5] Fix --- src/api/functions/organizations.ts | 6 ++++-- src/api/routes/organizations.ts | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/api/functions/organizations.ts b/src/api/functions/organizations.ts index 4244b18d..b45dd02b 100644 --- a/src/api/functions/organizations.ts +++ b/src/api/functions/organizations.ts @@ -186,6 +186,7 @@ export const addLead = async ({ logger, officersEmail, redisClient, + shouldSkipEnhancedActions, }: { user: z.infer; orgId: string; @@ -197,6 +198,7 @@ export const addLead = async ({ logger: FastifyBaseLogger; officersEmail: string; redisClient: Redis; + shouldSkipEnhancedActions: boolean; }): Promise => { const { username } = user; @@ -262,7 +264,7 @@ export const addLead = async ({ `Successfully added ${username} as lead for ${orgId} in DynamoDB.`, ); - if (entraGroupId) { + if (entraGroupId && !shouldSkipEnhancedActions) { await modifyGroup( entraIdToken, username, @@ -282,7 +284,7 @@ export const addLead = async ({ to: getAllUserEmails(username), cc: [officersEmail], subject: `${user.nonVotingMember ? "Non-voting lead" : "Lead"} added for ${orgId}`, - content: `Hello,\n\nWe're letting you know that ${username} has been added as a ${user.nonVotingMember ? "non-voting" : ""} lead for ${orgId} by ${actorUsername}. Changes may take up to 2 hours to reflect in all systems.`, + content: `Hello,\n\nWe're letting you know that ${username} has been added as a ${user.nonVotingMember ? "non-voting" : ""} lead for ${orgId} by ${actorUsername}.${shouldSkipEnhancedActions && "\nLeads for this org are not updated automatically in external systems (such as Entra ID). Please contact the appropriate administrators to make sure these updates are made.\n"}Changes may take up to 2 hours to reflect in all systems.`, }, }; }); diff --git a/src/api/routes/organizations.ts b/src/api/routes/organizations.ts index ff706969..8ad60a59 100644 --- a/src/api/routes/organizations.ts +++ b/src/api/routes/organizations.ts @@ -594,6 +594,7 @@ const organizationsPlugin: FastifyPluginAsync = async (fastify, _options) => { logger: request.log, officersEmail, redisClient: fastify.redisClient, + shouldSkipEnhancedActions, }; const addPromises = add.map((user) => addLead({ ...commonArgs, user })); From 97a6d54405a409b190ada82ebd0cc09443f27075 Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Sun, 12 Oct 2025 21:33:16 -0500 Subject: [PATCH 3/5] Do rollbacks as needed --- src/api/functions/organizations.ts | 394 +++++++++++++++++++---------- src/api/routes/organizations.ts | 1 - 2 files changed, 260 insertions(+), 135 deletions(-) diff --git a/src/api/functions/organizations.ts b/src/api/functions/organizations.ts index b45dd02b..3f9e4507 100644 --- a/src/api/functions/organizations.ts +++ b/src/api/functions/organizations.ts @@ -202,91 +202,154 @@ export const addLead = async ({ }): Promise => { const { username } = user; - const addOperation = async () => { - const addTransaction = new TransactWriteItemsCommand({ - TransactItems: [ - buildAuditLogTransactPut({ - entry: { - module: Modules.ORG_INFO, - actor: actorUsername, - target: username, - message: `Added target as a lead of ${orgId}.`, - }, - })!, - { - Put: { - TableName: genericConfig.SigInfoTableName, - Item: marshall( - { - ...user, - primaryKey: `LEAD#${orgId}`, - entryId: username, - updatedAt: new Date().toISOString(), - }, - { removeUndefinedValues: true }, - ), - ConditionExpression: - "attribute_not_exists(primaryKey) AND attribute_not_exists(entryId)", - }, - }, - ], - }); - - return await dynamoClient.send(addTransaction); - }; const lock = createLock({ adapter: new IoredisAdapter(redisClient), key: `user:${username}`, retryAttempts: 5, retryDelay: 300, }) as SimpleLock; + return await lock.using(async () => { + let entraAddSucceeded = false; + try { - await retryDynamoTransactionWithBackoff( - addOperation, - logger, - `Add lead ${username} to ${orgId}`, - ); - } catch (e: any) { - if ( - e.name === "TransactionCanceledException" && - e.message.includes("ConditionalCheckFailed") - ) { + // Step 1: Add to Entra ID first (if applicable) + if (entraGroupId && !shouldSkipEnhancedActions) { + logger.info( + `Adding ${username} to Entra group for ${orgId} (Group ID: ${entraGroupId}).`, + ); + + await modifyGroup( + entraIdToken, + username, + entraGroupId, + EntraGroupActions.ADD, + dynamoClient, + ); + + entraAddSucceeded = true; logger.info( - `User ${username} is already a lead for ${orgId}. Skipping add operation.`, + `Successfully added ${username} to Entra group for ${orgId}.`, ); - return null; } - throw e; - } - logger.info( - `Successfully added ${username} as lead for ${orgId} in DynamoDB.`, - ); + // Step 2: Add to DynamoDB + const addTransaction = new TransactWriteItemsCommand({ + TransactItems: [ + buildAuditLogTransactPut({ + entry: { + module: Modules.ORG_INFO, + actor: actorUsername, + target: username, + message: `Added target as a lead of ${orgId}.`, + }, + })!, + { + Put: { + TableName: genericConfig.SigInfoTableName, + Item: marshall( + { + ...user, + primaryKey: `LEAD#${orgId}`, + entryId: username, + updatedAt: new Date().toISOString(), + }, + { removeUndefinedValues: true }, + ), + ConditionExpression: + "attribute_not_exists(primaryKey) AND attribute_not_exists(entryId)", + }, + }, + ], + }); + + try { + await retryDynamoTransactionWithBackoff( + async () => await dynamoClient.send(addTransaction), + logger, + `Add lead ${username} to ${orgId}`, + ); + } catch (e: any) { + if ( + e.name === "TransactionCanceledException" && + e.message.includes("ConditionalCheckFailed") + ) { + logger.info( + `User ${username} is already a lead for ${orgId}. Rolling back Entra changes if needed.`, + ); + + // Rollback Entra ID if it was added + if (entraAddSucceeded && entraGroupId) { + logger.warn( + `Rolling back Entra group addition for ${username} in ${orgId}.`, + ); + try { + await modifyGroup( + entraIdToken, + username, + entraGroupId, + EntraGroupActions.REMOVE, + dynamoClient, + ); + logger.info( + `Successfully rolled back Entra group addition for ${username}.`, + ); + } catch (rollbackError) { + logger.error( + `CRITICAL: Failed to rollback Entra group addition for ${username} in ${orgId}. Manual intervention required.`, + rollbackError, + ); + } + } + + return null; + } + throw e; // Re-throw for outer catch block + } - if (entraGroupId && !shouldSkipEnhancedActions) { - await modifyGroup( - entraIdToken, - username, - entraGroupId, - EntraGroupActions.ADD, - dynamoClient, - ); logger.info( - `Successfully added ${username} to Entra group for ${orgId}.`, + `Successfully added ${username} as lead for ${orgId} in DynamoDB.`, ); - } - return { - function: AvailableSQSFunctions.EmailNotifications, - metadata: { initiator: actorUsername, reqId }, - payload: { - to: getAllUserEmails(username), - cc: [officersEmail], - subject: `${user.nonVotingMember ? "Non-voting lead" : "Lead"} added for ${orgId}`, - content: `Hello,\n\nWe're letting you know that ${username} has been added as a ${user.nonVotingMember ? "non-voting" : ""} lead for ${orgId} by ${actorUsername}.${shouldSkipEnhancedActions && "\nLeads for this org are not updated automatically in external systems (such as Entra ID). Please contact the appropriate administrators to make sure these updates are made.\n"}Changes may take up to 2 hours to reflect in all systems.`, - }, - }; + // Step 3: Send notification email + return { + function: AvailableSQSFunctions.EmailNotifications, + metadata: { initiator: actorUsername, reqId }, + payload: { + to: getAllUserEmails(username), + cc: [officersEmail], + subject: `${user.nonVotingMember ? "Non-voting lead" : "Lead"} added for ${orgId}`, + content: `Hello,\n\nWe're letting you know that ${username} has been added as a ${user.nonVotingMember ? "non-voting" : ""} lead for ${orgId} by ${actorUsername}.${shouldSkipEnhancedActions ? "\nLeads for this org are not updated automatically in external systems (such as Entra ID). Please contact the appropriate administrators to ensure these updates are made.\n" : "\n"}Changes may take up to 2 hours to reflect in all systems.`, + }, + }; + } catch (error) { + // Rollback Entra ID if DynamoDB operation failed + if (entraAddSucceeded && entraGroupId) { + logger.error( + `DynamoDB operation failed for ${username} in ${orgId}. Rolling back Entra group addition.`, + ); + try { + await modifyGroup( + entraIdToken, + username, + entraGroupId, + EntraGroupActions.REMOVE, + dynamoClient, + ); + logger.info( + `Successfully rolled back Entra group addition for ${username}.`, + ); + } catch (rollbackError) { + logger.error( + `CRITICAL: Failed to rollback Entra group addition for ${username} in ${orgId}. Manual intervention required.`, + rollbackError, + ); + } + } + + // Re-throw the original error + throw error; + } }); }; @@ -301,6 +364,7 @@ export const removeLead = async ({ logger, officersEmail, redisClient, + shouldSkipEnhancedActions, }: { username: string; orgId: string; @@ -312,35 +376,8 @@ export const removeLead = async ({ logger: FastifyBaseLogger; officersEmail: string; redisClient: Redis; + shouldSkipEnhancedActions: boolean; }): Promise => { - const removeOperation = async () => { - const removeTransaction = new TransactWriteItemsCommand({ - TransactItems: [ - buildAuditLogTransactPut({ - entry: { - module: Modules.ORG_INFO, - actor: actorUsername, - target: username, - message: `Removed target from lead of ${orgId}.`, - }, - })!, - { - Delete: { - TableName: genericConfig.SigInfoTableName, - Key: marshall({ - primaryKey: `LEAD#${orgId}`, - entryId: username, - }), - ConditionExpression: - "attribute_exists(primaryKey) AND attribute_exists(entryId)", - }, - }, - ], - }); - - return await dynamoClient.send(removeTransaction); - }; - const lock = createLock({ adapter: new IoredisAdapter(redisClient), key: `user:${username}`, @@ -349,52 +386,141 @@ export const removeLead = async ({ }) as SimpleLock; return await lock.using(async () => { + let entraRemoveSucceeded = false; + try { - await retryDynamoTransactionWithBackoff( - removeOperation, - logger, - `Remove lead ${username} from ${orgId}`, - ); - } catch (e: any) { - if ( - e.name === "TransactionCanceledException" && - e.message.includes("ConditionalCheckFailed") - ) { + // Step 1: Remove from Entra ID first (if applicable) + if (entraGroupId && !shouldSkipEnhancedActions) { + logger.info( + `Removing ${username} from Entra group for ${orgId} (Group ID: ${entraGroupId}).`, + ); + + await modifyGroup( + entraIdToken, + username, + entraGroupId, + EntraGroupActions.REMOVE, + dynamoClient, + ); + + entraRemoveSucceeded = true; logger.info( - `User ${username} was not a lead for ${orgId}. Skipping remove operation.`, + `Successfully removed ${username} from Entra group for ${orgId}.`, ); - return null; } - throw e; - } - logger.info( - `Successfully removed ${username} as lead for ${orgId} in DynamoDB.`, - ); + // Step 2: Remove from DynamoDB + const removeTransaction = new TransactWriteItemsCommand({ + TransactItems: [ + buildAuditLogTransactPut({ + entry: { + module: Modules.ORG_INFO, + actor: actorUsername, + target: username, + message: `Removed target from lead of ${orgId}.`, + }, + })!, + { + Delete: { + TableName: genericConfig.SigInfoTableName, + Key: marshall({ + primaryKey: `LEAD#${orgId}`, + entryId: username, + }), + ConditionExpression: + "attribute_exists(primaryKey) AND attribute_exists(entryId)", + }, + }, + ], + }); + + try { + await retryDynamoTransactionWithBackoff( + async () => await dynamoClient.send(removeTransaction), + logger, + `Remove lead ${username} from ${orgId}`, + ); + } catch (e: any) { + if ( + e.name === "TransactionCanceledException" && + e.message.includes("ConditionalCheckFailed") + ) { + logger.info( + `User ${username} was not a lead for ${orgId}. Rolling back Entra changes if needed.`, + ); + + // Rollback Entra ID if it was removed + if (entraRemoveSucceeded && entraGroupId) { + logger.warn( + `Rolling back Entra group removal for ${username} in ${orgId}.`, + ); + try { + await modifyGroup( + entraIdToken, + username, + entraGroupId, + EntraGroupActions.ADD, + dynamoClient, + ); + logger.info( + `Successfully rolled back Entra group removal for ${username}.`, + ); + } catch (rollbackError) { + logger.error( + `CRITICAL: Failed to rollback Entra group removal for ${username} in ${orgId}. Manual intervention required.`, + rollbackError, + ); + } + } + + return null; + } + throw e; // Re-throw for outer catch block + } - if (entraGroupId) { - await modifyGroup( - entraIdToken, - username, - entraGroupId, - EntraGroupActions.REMOVE, - dynamoClient, - ); logger.info( - `Successfully removed ${username} from Entra group for ${orgId}.`, + `Successfully removed ${username} as lead for ${orgId} in DynamoDB.`, ); - } - return { - function: AvailableSQSFunctions.EmailNotifications, - metadata: { initiator: actorUsername, reqId }, - payload: { - to: getAllUserEmails(username), - cc: [officersEmail], - subject: `Lead removed for ${orgId}`, - content: `Hello,\n\nWe're letting you know that ${username} has been removed as a lead for ${orgId} by ${actorUsername}.\n\nNo action is required from you at this time.`, - }, - }; + // Step 3: Send notification email + return { + function: AvailableSQSFunctions.EmailNotifications, + metadata: { initiator: actorUsername, reqId }, + payload: { + to: getAllUserEmails(username), + cc: [officersEmail], + subject: `Lead removed for ${orgId}`, + content: `Hello,\n\nWe're letting you know that ${username} has been removed as a lead for ${orgId} by ${actorUsername}.${shouldSkipEnhancedActions ? "\nLeads for this org are not updated automatically in external systems (such as Entra ID). Please contact the appropriate administrators to make sure these updates are made.\n" : "\n"}No action is required from you at this time.`, + }, + }; + } catch (error) { + // Rollback Entra ID if DynamoDB operation failed + if (entraRemoveSucceeded && entraGroupId) { + logger.error( + `DynamoDB operation failed for ${username} in ${orgId}. Rolling back Entra group removal.`, + ); + try { + await modifyGroup( + entraIdToken, + username, + entraGroupId, + EntraGroupActions.ADD, + dynamoClient, + ); + logger.info( + `Successfully rolled back Entra group removal for ${username}.`, + ); + } catch (rollbackError) { + logger.error( + `CRITICAL: Failed to rollback Entra group removal for ${username} in ${orgId}. Manual intervention required.`, + rollbackError, + ); + } + } + + // Re-throw the original error + throw error; + } }); }; diff --git a/src/api/routes/organizations.ts b/src/api/routes/organizations.ts index 8ad60a59..7ff4d8b5 100644 --- a/src/api/routes/organizations.ts +++ b/src/api/routes/organizations.ts @@ -32,7 +32,6 @@ import { AppRoles } from "common/roles.js"; import { DynamoDBClient, GetItemCommand, - TransactWriteItem, TransactWriteItemsCommand, } from "@aws-sdk/client-dynamodb"; import { From 439ce82b722955de4f88815cf323f6369253fa30 Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Sun, 12 Oct 2025 21:39:23 -0500 Subject: [PATCH 4/5] Fix unit tests --- tests/unit/organizations.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/unit/organizations.test.ts b/tests/unit/organizations.test.ts index afe8d92f..7dda0b0f 100644 --- a/tests/unit/organizations.test.ts +++ b/tests/unit/organizations.test.ts @@ -378,7 +378,7 @@ describe("Organization info tests - Extended Coverage", () => { Items: [ marshall({ username: "oldlead@illinois.edu", - primaryKey: "LEAD#ACM", + primaryKey: "LEAD#Social Committee", }), ], }); @@ -397,7 +397,7 @@ describe("Organization info tests - Extended Coverage", () => { const response = await app.inject({ method: "PATCH", - url: "/api/v1/organizations/ACM/leads", + url: "/api/v1/organizations/Social Committee/leads", headers: { authorization: `Bearer ${testJwt}` }, payload: { add: [ @@ -420,8 +420,8 @@ describe("Organization info tests - Extended Coverage", () => { expect.objectContaining({ githubToken: "abc123testing", orgId: "acm-uiuc-testing", - name: "acm-adm-nonprod", - description: "ACM Admin", + name: "social-adm-nonprod", + description: "Social Committee Admin", parentTeamId: 14420860, }), ); From 81b21852f4647426b5197b3d4facb83665450ed0 Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Sun, 12 Oct 2025 21:48:31 -0500 Subject: [PATCH 5/5] Test that the update isn't made --- tests/unit/organizations.test.ts | 66 ++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/tests/unit/organizations.test.ts b/tests/unit/organizations.test.ts index 7dda0b0f..8d5bb657 100644 --- a/tests/unit/organizations.test.ts +++ b/tests/unit/organizations.test.ts @@ -17,6 +17,8 @@ import { marshall } from "@aws-sdk/util-dynamodb"; import { genericConfig } from "../../src/common/config.js"; import { randomUUID } from "node:crypto"; import { createGithubTeam } from "../../src/api/functions/github.js"; +import { addLead, removeLead } from "../../src/api/functions/organizations.js"; +import { modifyGroup } from "../../src/api/functions/entraId.js"; const app = await init(); const ddbMock = mockClient(DynamoDBClient); @@ -427,6 +429,70 @@ describe("Organization info tests - Extended Coverage", () => { ); }); + test("Successfully adds and removes Officers but skips Entra + GitHub integration", async () => { + const testJwt = createJwt(); + + // Mock GetItemCommand for org metadata + ddbMock + .on(GetItemCommand, { TableName: genericConfig.SigInfoTableName }) + .resolves({ + Item: marshall({ leadsEntraGroupID: "abc" }), + }); + // Mock getUserOrgRoles to return LEAD role for this user + ddbMock + .on(QueryCommand, { + TableName: genericConfig.SigInfoTableName, + IndexName: "UsernameIndex", + KeyConditionExpression: `username = :username`, + ExpressionAttributeValues: { + ":username": { S: "oldlead@illinois.edu" }, + }, + }) + .resolves({ + Items: [ + marshall({ + username: "oldlead@illinois.edu", + primaryKey: "LEAD#ACM", + }), + ], + }); + ddbMock.on(TransactWriteItemsCommand).resolves({}); + + sqsMock.on(SendMessageBatchCommand).resolves({ + Successful: [ + { + Id: "1", + MessageId: "msg-1", + MD5OfMessageBody: "mock-md5", + }, + ], + Failed: [], + }); + + const response = await app.inject({ + method: "PATCH", + url: "/api/v1/organizations/ACM/leads", + headers: { authorization: `Bearer ${testJwt}` }, + payload: { + add: [ + { + username: "newlead@illinois.edu", + name: "New Lead", + title: "President", + }, + ], + remove: ["oldlead@illinois.edu"], + }, + }); + + expect(response.statusCode).toBe(201); + expect( + ddbMock.commandCalls(TransactWriteItemsCommand).length, + ).toBeGreaterThan(0); + expect(createGithubTeam).toHaveBeenCalledTimes(0); + expect(modifyGroup).toHaveBeenCalledTimes(0); + }); + test("Organization lead can manage other leads", async () => { const testJwt = createJwt();