diff --git a/src/api/functions/github.ts b/src/api/functions/github.ts index 8b19ad14..11759531 100644 --- a/src/api/functions/github.ts +++ b/src/api/functions/github.ts @@ -6,7 +6,7 @@ import { Octokit } from "octokit"; export interface CreateGithubTeamInputs { githubToken: string; orgId: string; - parentTeamId: number; + parentTeamId?: number; name: string; description?: string; privacy?: "secret" | "closed"; diff --git a/src/api/functions/organizations.ts b/src/api/functions/organizations.ts index 3f9e4507..89de3650 100644 --- a/src/api/functions/organizations.ts +++ b/src/api/functions/organizations.ts @@ -101,7 +101,13 @@ export async function getOrgInfo({ name: x.name, username: x.username, title: x.title, - }) as { name: string; username: string; title: string | undefined }, + nonVotingMember: x.nonVotingMember || false, + }) as { + name: string; + username: string; + title: string | undefined; + nonVotingMember: boolean; + }, ); response = { ...response, leads: unmarshalledLeads }; } @@ -525,42 +531,106 @@ export const removeLead = async ({ }; /** - * Returns the Microsoft 365 Dynamic User query to return all members of all lead groups. - * Currently used to setup the Exec member list. + * Returns all voting org leads across all organizations. + * Uses consistent reads to avoid eventual consistency issues. * @param dynamoClient A DynamoDB client. - * @param includeGroupIds Used to ensure that a specific group ID is included (Scan could be eventually consistent.) + * @param logger A logger instance. */ -export async function getLeadsM365DynamicQuery({ +export async function getAllVotingLeads({ dynamoClient, - includeGroupIds, + logger, }: { dynamoClient: DynamoDBClient; - includeGroupIds?: string[]; -}): Promise { - const command = new ScanCommand({ - TableName: genericConfig.SigInfoTableName, - IndexName: "LeadsGroupIdIndex", + logger: ValidLoggers; +}): Promise< + Array<{ username: string; org: string; name: string; title: string }> +> { + // Query all organizations in parallel for better performance + const queryPromises = AllOrganizationNameList.map(async (orgName) => { + const leadsQuery = new QueryCommand({ + TableName: genericConfig.SigInfoTableName, + KeyConditionExpression: "primaryKey = :leadName", + ExpressionAttributeValues: { + ":leadName": { S: `LEAD#${orgName}` }, + }, + ConsistentRead: true, + }); + + try { + const responseMarshall = await dynamoClient.send(leadsQuery); + if (responseMarshall.Items) { + return responseMarshall.Items.map((x) => unmarshall(x)) + .filter((x) => x.username && !x.nonVotingMember) + .map((x) => ({ + username: x.username as string, + org: orgName, + name: x.name as string, + title: x.title as string, + })); + } + return []; + } catch (e) { + if (e instanceof BaseError) { + throw e; + } + logger.error(e); + throw new DatabaseFetchError({ + message: `Failed to get leads for org ${orgName}.`, + }); + } }); - const results = await dynamoClient.send(command); - if (!results || !results.Items || results.Items.length === 0) { - return null; - } - const entries = results.Items.map((x) => unmarshall(x)) as { - primaryKey: string; - leadsEntraGroupId: string; - }[]; - const groupIds = entries - .filter((x) => x.primaryKey.startsWith("DEFINE#")) - .map((x) => x.leadsEntraGroupId); - - if (groupIds.length === 0) { - return null; + + const results = await Promise.all(queryPromises); + return results.flat(); +} + +/** + * Checks if a user should remain in exec council by verifying they are a voting lead of at least one org. + * Uses consistent reads to avoid eventual consistency issues. + * @param username The username to check. + * @param dynamoClient A DynamoDB client. + * @param logger A logger instance. + */ +export async function shouldBeInExecCouncil({ + username, + dynamoClient, + logger, +}: { + username: string; + dynamoClient: DynamoDBClient; + logger: ValidLoggers; +}): Promise { + // Query all orgs to see if this user is a voting lead of any org + for (const orgName of AllOrganizationNameList) { + const leadsQuery = new QueryCommand({ + TableName: genericConfig.SigInfoTableName, + KeyConditionExpression: "primaryKey = :leadName AND entryId = :username", + ExpressionAttributeValues: { + ":leadName": { S: `LEAD#${orgName}` }, + ":username": { S: username }, + }, + ConsistentRead: true, + }); + + try { + const responseMarshall = await dynamoClient.send(leadsQuery); + if (responseMarshall.Items && responseMarshall.Items.length > 0) { + const lead = unmarshall(responseMarshall.Items[0]); + // If they're a lead and not a non-voting member, they should be in exec + if (!lead.nonVotingMember) { + return true; + } + } + } catch (e) { + if (e instanceof BaseError) { + throw e; + } + logger.error(e); + throw new DatabaseFetchError({ + message: `Failed to check lead status for ${username} in org ${orgName}.`, + }); + } } - const formattedGroupIds = [ - ...new Set([...(includeGroupIds || []), ...groupIds]), - ] - .map((id) => `'${id}'`) - .join(", "); - return `user.memberOf -any (group.objectId -in [${formattedGroupIds}])`; + return false; } diff --git a/src/api/routes/organizations.ts b/src/api/routes/organizations.ts index 2a73b6e5..3faf0142 100644 --- a/src/api/routes/organizations.ts +++ b/src/api/routes/organizations.ts @@ -1,9 +1,5 @@ -import { FastifyError, FastifyPluginAsync } from "fastify"; -import { - AllOrganizationNameList, - getOrgByName, - Organizations, -} from "@acm-uiuc/js-shared"; +import { FastifyPluginAsync } from "fastify"; +import { AllOrganizationNameList, getOrgByName } from "@acm-uiuc/js-shared"; import rateLimiter from "api/plugins/rateLimiter.js"; import { withRoles, withTags } from "api/components/index.js"; import { z } from "zod/v4"; @@ -23,7 +19,6 @@ import { } from "common/errors/index.js"; import { addLead, - getLeadsM365DynamicQuery, getOrgInfo, removeLead, SQSMessage, @@ -35,8 +30,6 @@ import { TransactWriteItemsCommand, } from "@aws-sdk/client-dynamodb"; import { - execCouncilGroupId, - execCouncilTestingGroupId, genericConfig, notificationRecipients, roleArns, @@ -46,20 +39,12 @@ import { buildAuditLogTransactPut } from "api/functions/auditLog.js"; import { Modules } from "common/modules.js"; import { authorizeByOrgRoleOrSchema } from "api/functions/authorization.js"; import { checkPaidMembership } from "api/functions/membership.js"; -import { - createM365Group, - getEntraIdToken, - setGroupMembershipRule, -} from "api/functions/entraId.js"; +import { createM365Group, getEntraIdToken } from "api/functions/entraId.js"; import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager"; import { getRoleCredentials } from "api/functions/sts.js"; -import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs"; +import { SQSClient } from "@aws-sdk/client-sqs"; import { sendSqsMessagesInBatches } from "api/functions/sqs.js"; import { retryDynamoTransactionWithBackoff } from "api/utils.js"; -import { - assignIdpGroupsToTeam, - createGithubTeam, -} from "api/functions/github.js"; import { SKIP_EXTERNAL_ORG_LEAD_UPDATE } from "common/overrides.js"; import { AvailableSQSFunctions, SQSPayload } from "common/types/sqsMessage.js"; @@ -499,20 +484,8 @@ const organizationsPlugin: FastifyPluginAsync = async (fastify, _options) => { `Store Entra group ID for ${request.params.orgId}`, ); - // Update dynamic membership query - const newQuery = await getLeadsM365DynamicQuery({ - dynamoClient: fastify.dynamoClient, - includeGroupIds: [entraGroupId], - }); - if (newQuery) { - const groupToUpdate = - fastify.runEnvironment === "prod" - ? execCouncilGroupId - : execCouncilTestingGroupId; - request.log.info("Changing Exec group membership dynamic query..."); - await setGroupMembershipRule(entraIdToken, groupToUpdate, newQuery); - request.log.info("Changed Exec group membership dynamic query!"); - } + // Note: Exec council membership is now managed via SQS sync handler + // instead of dynamic membership rules } catch (e) { request.log.error(e, "Failed to create Entra group"); throw new InternalServerError({ @@ -589,6 +562,19 @@ const organizationsPlugin: FastifyPluginAsync = async (fastify, _options) => { }; sqsPayloads.push(sqsPayload); } + + // Queue exec council sync to ensure voting members are added/removed + const syncExecPayload: SQSPayload = + { + function: AvailableSQSFunctions.SyncExecCouncil, + metadata: { + initiator: request.username!, + reqId: request.id, + }, + payload: {}, + }; + sqsPayloads.push(syncExecPayload); + if (sqsPayloads.length > 0) { await sendSqsMessagesInBatches({ sqsClient: fastify.sqsClient, diff --git a/src/api/sqs/handlers/createOrgGithubTeam.ts b/src/api/sqs/handlers/createOrgGithubTeam.ts index 8e19b54a..fdfaef1c 100644 --- a/src/api/sqs/handlers/createOrgGithubTeam.ts +++ b/src/api/sqs/handlers/createOrgGithubTeam.ts @@ -91,7 +91,7 @@ export const createOrgGithubTeamHandler: SQSHandlerFunction< const { updated, id: teamId } = await createGithubTeam({ orgId: currentEnvironmentConfig.GithubOrgName, githubToken: secretConfig.github_pat, - parentTeamId: currentEnvironmentConfig.ExecGithubTeam, + parentTeamId: currentEnvironmentConfig.OrgAdminGithubParentTeam, name: finalName, description: githubTeamDescription, logger, diff --git a/src/api/sqs/handlers/index.ts b/src/api/sqs/handlers/index.ts index c5502074..0c5729c7 100644 --- a/src/api/sqs/handlers/index.ts +++ b/src/api/sqs/handlers/index.ts @@ -4,3 +4,4 @@ export { provisionNewMemberHandler } from "./provisionNewMember.js"; export { sendSaleEmailHandler } from "./sendSaleEmailHandler.js"; export { emailNotificationsHandler } from "./emailNotifications.js"; export { createOrgGithubTeamHandler } from "./createOrgGithubTeam.js"; +export { syncExecCouncilHandler } from "./syncExecCouncil.js"; diff --git a/src/api/sqs/handlers/syncExecCouncil.ts b/src/api/sqs/handlers/syncExecCouncil.ts new file mode 100644 index 00000000..2e3bd069 --- /dev/null +++ b/src/api/sqs/handlers/syncExecCouncil.ts @@ -0,0 +1,173 @@ +import { AvailableSQSFunctions } from "common/types/sqsMessage.js"; +import { + currentEnvironmentConfig, + runEnvironment, + SQSHandlerFunction, +} from "../index.js"; +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { + execCouncilGroupId, + execCouncilTestingGroupId, + genericConfig, + roleArns, +} from "common/config.js"; +import { getAllVotingLeads } from "api/functions/organizations.js"; +import { + getEntraIdToken, + listGroupMembers, + modifyGroup, +} from "api/functions/entraId.js"; +import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager"; +import { EntraGroupActions } from "common/types/iam.js"; +import { getRoleCredentials } from "api/functions/sts.js"; + +export const syncExecCouncilHandler: SQSHandlerFunction< + AvailableSQSFunctions.SyncExecCouncil +> = async (_payload, _metadata, logger) => { + const getAuthorizedClients = async () => { + if (roleArns.Entra) { + logger.info( + `Attempting to assume Entra role ${roleArns.Entra} to get the Entra token...`, + ); + const credentials = await getRoleCredentials(roleArns.Entra); + const clients = { + smClient: new SecretsManagerClient({ + region: genericConfig.AwsRegion, + credentials, + }), + dynamoClient: new DynamoDBClient({ + region: genericConfig.AwsRegion, + credentials, + }), + }; + logger.info( + `Assumed Entra role ${roleArns.Entra} to get the Entra token.`, + ); + return clients; + } + logger.debug("Did not assume Entra role as no env variable was present"); + return { + smClient: new SecretsManagerClient({ + region: genericConfig.AwsRegion, + }), + dynamoClient: new DynamoDBClient({ + region: genericConfig.AwsRegion, + }), + }; + }; + + const dynamo = new DynamoDBClient({ + region: genericConfig.AwsRegion, + }); + + try { + const clients = await getAuthorizedClients(); + const entraIdToken = await getEntraIdToken({ + clients, + clientId: currentEnvironmentConfig.AadValidClientId, + secretName: genericConfig.EntraSecretName, + logger, + }); + + // Determine which exec council group to use based on environment + const execCouncilGroup = + runEnvironment === "prod" + ? execCouncilGroupId + : execCouncilTestingGroupId; + + logger.info( + `Syncing exec council membership for group ${execCouncilGroup}...`, + ); + + // Get all voting leads from DynamoDB with consistent reads + const votingLeads = await getAllVotingLeads({ + dynamoClient: dynamo, + logger, + }); + + // Convert to set of usernames (without @illinois.edu) + const votingLeadUsernames = new Set( + votingLeads.map((lead) => lead.username), + ); + + logger.info( + `Found ${votingLeadUsernames.size} voting leads across all organizations.`, + ); + + // Get current exec council members from Entra ID + const currentMembers = await listGroupMembers( + entraIdToken, + execCouncilGroup, + ); + + // Convert to set of emails + const currentMemberEmails = new Set( + currentMembers + .map((member) => member.email) + .filter((email) => email && email.endsWith("@illinois.edu")), + ); + + logger.info( + `Current exec council has ${currentMemberEmails.size} members from @illinois.edu domain.`, + ); + + // Determine who to add and who to remove + const toAdd = Array.from(votingLeadUsernames).filter( + (username) => !currentMemberEmails.has(username), + ); + const toRemove = Array.from(currentMemberEmails).filter( + (email) => !votingLeadUsernames.has(email), + ); + + logger.info( + `Will add ${toAdd.length} members and remove ${toRemove.length} members.`, + ); + + // Add missing voting leads to exec council + for (const username of toAdd) { + try { + logger.info(`Adding ${username} to exec council...`); + await modifyGroup( + entraIdToken, + username, + execCouncilGroup, + EntraGroupActions.ADD, + dynamo, + ); + logger.info(`Successfully added ${username} to exec council.`); + } catch (error) { + logger.error( + error, + `Failed to add ${username} to exec council. Continuing with other members...`, + ); + } + } + + // Remove non-voting leads from exec council + for (const email of toRemove) { + try { + logger.info(`Removing ${email} from exec council...`); + await modifyGroup( + entraIdToken, + email, + execCouncilGroup, + EntraGroupActions.REMOVE, + dynamo, + ); + logger.info(`Successfully removed ${email} from exec council.`); + } catch (error) { + logger.error( + error, + `Failed to remove ${email} from exec council. Continuing with other members...`, + ); + } + } + + logger.info( + `Exec council sync completed. Added ${toAdd.length}, removed ${toRemove.length}.`, + ); + } catch (error) { + logger.error(error, "Failed to sync exec council membership"); + throw error; + } +}; diff --git a/src/api/sqs/index.ts b/src/api/sqs/index.ts index 5ab600fd..97d2c081 100644 --- a/src/api/sqs/index.ts +++ b/src/api/sqs/index.ts @@ -18,11 +18,12 @@ import { provisionNewMemberHandler, sendSaleEmailHandler, emailNotificationsHandler, + createOrgGithubTeamHandler, + syncExecCouncilHandler, } from "./handlers/index.js"; import { ValidationError } from "../../common/errors/index.js"; import { RunEnvironment } from "../../common/roles.js"; import { environmentConfig } from "../../common/config.js"; -import { createOrgGithubTeamHandler } from "./handlers/createOrgGithubTeam.js"; export type SQSFunctionPayloadTypes = { [K in keyof typeof sqsPayloadSchemas]: SQSHandlerFunction; @@ -41,6 +42,7 @@ const handlers: SQSFunctionPayloadTypes = { [AvailableSQSFunctions.SendSaleEmail]: sendSaleEmailHandler, [AvailableSQSFunctions.EmailNotifications]: emailNotificationsHandler, [AvailableSQSFunctions.CreateOrgGithubTeam]: createOrgGithubTeamHandler, + [AvailableSQSFunctions.SyncExecCouncil]: syncExecCouncilHandler, }; export const runEnvironment = process.env.RunEnvironment as RunEnvironment; export const currentEnvironmentConfig = environmentConfig[runEnvironment]; diff --git a/src/common/config.ts b/src/common/config.ts index 618f8fe6..28e7e362 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -30,7 +30,7 @@ export type ConfigType = { GroupSuffix: string; GroupEmailSuffix: string; GithubOrgName: string; - ExecGithubTeam: number; + OrgAdminGithubParentTeam: number; GithubIdpSyncEnabled: boolean GithubOrgId: number; }; @@ -141,7 +141,7 @@ const environmentConfig: EnvironmentConfigType = { GroupEmailSuffix: "nonprod", GithubOrgName: "acm-uiuc-testing", GithubOrgId: 235748315, - ExecGithubTeam: 14420860, + OrgAdminGithubParentTeam: 14420860, GithubIdpSyncEnabled: false }, prod: { @@ -173,7 +173,7 @@ const environmentConfig: EnvironmentConfigType = { GroupEmailSuffix: "", GithubOrgName: "acm-uiuc", GithubOrgId: 425738, - ExecGithubTeam: 12025214, + OrgAdminGithubParentTeam: 12025214, GithubIdpSyncEnabled: true }, }; diff --git a/src/common/types/sqsMessage.ts b/src/common/types/sqsMessage.ts index 1aec1c1a..0572f591 100644 --- a/src/common/types/sqsMessage.ts +++ b/src/common/types/sqsMessage.ts @@ -8,6 +8,7 @@ export enum AvailableSQSFunctions { SendSaleEmail = "sendSaleEmail", EmailNotifications = "emailNotifications", CreateOrgGithubTeam = "createOrgGithubTeam", + SyncExecCouncil = "syncExecCouncil", } const sqsMessageMetadataSchema = z.object({ @@ -77,6 +78,9 @@ export const sqsPayloadSchemas = { githubTeamName: z.string().min(1), githubTeamDescription: z.string().min(1) }) + ), + [AvailableSQSFunctions.SyncExecCouncil]: createSQSSchema( + AvailableSQSFunctions.SyncExecCouncil, z.object({}) ) } as const; @@ -94,7 +98,8 @@ export const sqsPayloadSchema = z.discriminatedUnion("function", [ sqsPayloadSchemas[AvailableSQSFunctions.ProvisionNewMember], sqsPayloadSchemas[AvailableSQSFunctions.SendSaleEmail], sqsPayloadSchemas[AvailableSQSFunctions.EmailNotifications], - sqsPayloadSchemas[AvailableSQSFunctions.CreateOrgGithubTeam] + sqsPayloadSchemas[AvailableSQSFunctions.CreateOrgGithubTeam], + sqsPayloadSchemas[AvailableSQSFunctions.SyncExecCouncil] ] as const); diff --git a/src/ui/pages/organization/ManageOrganizationForm.tsx b/src/ui/pages/organization/ManageOrganizationForm.tsx index 69f44dfd..f73dcb22 100644 --- a/src/ui/pages/organization/ManageOrganizationForm.tsx +++ b/src/ui/pages/organization/ManageOrganizationForm.tsx @@ -570,7 +570,7 @@ export const ManageOrganizationForm: React.FC = ({ setNewLeadNonVoting(e.currentTarget.checked)} /> @@ -587,8 +587,8 @@ export const ManageOrganizationForm: React.FC = ({ This lead will have management permissions but will not - have voting rights in Executive Council meetings. Use this - designation carefully. + have voting rights for your organization in Executive + Council meetings. Use this designation carefully. )} diff --git a/terraform/modules/lambdas/main.tf b/terraform/modules/lambdas/main.tf index 8d55831b..7686006d 100644 --- a/terraform/modules/lambdas/main.tf +++ b/terraform/modules/lambdas/main.tf @@ -486,11 +486,6 @@ resource "aws_iam_role_policy" "linkry_lambda_edge_dynamodb" { }) } -resource "aws_cloudwatch_log_group" "lambda_edge" { - name = "/aws/lambda/us-east-1.${aws_lambda_function.linkry_edge.function_name}" - retention_in_days = var.LogRetentionDays -} - resource "aws_lambda_function" "linkry_edge" { region = "us-east-1" filename = data.archive_file.linkry_edge_lambda_code.output_path diff --git a/tests/unit/organizations.test.ts b/tests/unit/organizations.test.ts index ec326b5e..e46055d8 100644 --- a/tests/unit/organizations.test.ts +++ b/tests/unit/organizations.test.ts @@ -120,6 +120,229 @@ describe("Organization info tests - Extended Coverage", () => { expect(response.headers["cache-control"]).toContain("public"); expect(response.headers["cache-control"]).toContain("max-age=300"); }); + + test("Returns nonVotingMember true when lead is non-voting", async () => { + const orgMetaWithLeads = { + primaryKey: "DEFINE#ACM", + leadsEntraGroupId: "a3c37a24-1e21-4338-813f-15478eb40137", + website: "https://www.acm.illinois.edu", + }; + + const nonVotingLead = { + primaryKey: "LEAD#ACM", + entryId: "nonvoting@illinois.edu", + username: "nonvoting@illinois.edu", + name: "Non Voting Lead", + title: "Advisor", + nonVotingMember: true, + }; + + ddbMock + .on(QueryCommand, { + KeyConditionExpression: "primaryKey = :definitionId", + }) + .resolves({ + Items: [marshall(orgMetaWithLeads)], + }) + .on(QueryCommand, { + KeyConditionExpression: "primaryKey = :leadName", + }) + .resolves({ + Items: [marshall(nonVotingLead)], + }); + + const response = await app.inject({ + method: "GET", + url: "/api/v1/organizations", + }); + + expect(response.statusCode).toBe(200); + const responseJson = response.json(); + const acmOrg = responseJson.find((org: any) => org.id === "ACM"); + expect(acmOrg).toBeDefined(); + expect(acmOrg.leads).toBeDefined(); + expect(acmOrg.leads.length).toBeGreaterThan(0); + const nonVotingLeadResponse = acmOrg.leads.find( + (lead: any) => lead.username === "nonvoting@illinois.edu", + ); + expect(nonVotingLeadResponse).toBeDefined(); + expect(nonVotingLeadResponse.nonVotingMember).toBe(true); + }); + + test("Returns nonVotingMember false when lead is voting member", async () => { + const orgMetaWithLeads = { + primaryKey: "DEFINE#ACM", + leadsEntraGroupId: "a3c37a24-1e21-4338-813f-15478eb40137", + website: "https://www.acm.illinois.edu", + }; + + const votingLead = { + primaryKey: "LEAD#ACM", + entryId: "voting@illinois.edu", + username: "voting@illinois.edu", + name: "Voting Lead", + title: "President", + nonVotingMember: false, + }; + + ddbMock + .on(QueryCommand, { + KeyConditionExpression: "primaryKey = :definitionId", + }) + .resolves({ + Items: [marshall(orgMetaWithLeads)], + }) + .on(QueryCommand, { + KeyConditionExpression: "primaryKey = :leadName", + }) + .resolves({ + Items: [marshall(votingLead)], + }); + + const response = await app.inject({ + method: "GET", + url: "/api/v1/organizations", + }); + + expect(response.statusCode).toBe(200); + const responseJson = response.json(); + const acmOrg = responseJson.find((org: any) => org.id === "ACM"); + expect(acmOrg).toBeDefined(); + expect(acmOrg.leads).toBeDefined(); + expect(acmOrg.leads.length).toBeGreaterThan(0); + const votingLeadResponse = acmOrg.leads.find( + (lead: any) => lead.username === "voting@illinois.edu", + ); + expect(votingLeadResponse).toBeDefined(); + expect(votingLeadResponse.nonVotingMember).toBe(false); + }); + + test("Returns nonVotingMember false by default when not specified in data", async () => { + const orgMetaWithLeads = { + primaryKey: "DEFINE#ACM", + leadsEntraGroupId: "a3c37a24-1e21-4338-813f-15478eb40137", + website: "https://www.acm.illinois.edu", + }; + + const leadWithoutNonVotingField = { + primaryKey: "LEAD#ACM", + entryId: "default@illinois.edu", + username: "default@illinois.edu", + name: "Default Lead", + title: "Vice President", + // nonVotingMember field not included + }; + + ddbMock + .on(QueryCommand, { + KeyConditionExpression: "primaryKey = :definitionId", + }) + .resolves({ + Items: [marshall(orgMetaWithLeads)], + }) + .on(QueryCommand, { + KeyConditionExpression: "primaryKey = :leadName", + }) + .resolves({ + Items: [marshall(leadWithoutNonVotingField)], + }); + + const response = await app.inject({ + method: "GET", + url: "/api/v1/organizations", + }); + + expect(response.statusCode).toBe(200); + const responseJson = response.json(); + const acmOrg = responseJson.find((org: any) => org.id === "ACM"); + expect(acmOrg).toBeDefined(); + expect(acmOrg.leads).toBeDefined(); + expect(acmOrg.leads.length).toBeGreaterThan(0); + const defaultLeadResponse = acmOrg.leads.find( + (lead: any) => lead.username === "default@illinois.edu", + ); + expect(defaultLeadResponse).toBeDefined(); + expect(defaultLeadResponse.nonVotingMember).toBe(false); + }); + + test("Returns multiple leads with mixed voting statuses", async () => { + const orgMetaWithLeads = { + primaryKey: "DEFINE#ACM", + leadsEntraGroupId: "a3c37a24-1e21-4338-813f-15478eb40137", + website: "https://www.acm.illinois.edu", + }; + + const votingLead = { + primaryKey: "LEAD#ACM", + entryId: "voting@illinois.edu", + username: "voting@illinois.edu", + name: "Voting Lead", + title: "President", + nonVotingMember: false, + }; + + const nonVotingLead = { + primaryKey: "LEAD#ACM", + entryId: "nonvoting@illinois.edu", + username: "nonvoting@illinois.edu", + name: "Non Voting Lead", + title: "Advisor", + nonVotingMember: true, + }; + + const defaultLead = { + primaryKey: "LEAD#ACM", + entryId: "default@illinois.edu", + username: "default@illinois.edu", + name: "Default Lead", + title: "Treasurer", + }; + + ddbMock + .on(QueryCommand, { + KeyConditionExpression: "primaryKey = :definitionId", + }) + .resolves({ + Items: [marshall(orgMetaWithLeads)], + }) + .on(QueryCommand, { + KeyConditionExpression: "primaryKey = :leadName", + }) + .resolves({ + Items: [ + marshall(votingLead), + marshall(nonVotingLead), + marshall(defaultLead), + ], + }); + + const response = await app.inject({ + method: "GET", + url: "/api/v1/organizations", + }); + + expect(response.statusCode).toBe(200); + const responseJson = response.json(); + const acmOrg = responseJson.find((org: any) => org.id === "ACM"); + expect(acmOrg).toBeDefined(); + expect(acmOrg.leads).toBeDefined(); + expect(acmOrg.leads.length).toBe(3); + + const votingLeadResponse = acmOrg.leads.find( + (lead: any) => lead.username === "voting@illinois.edu", + ); + expect(votingLeadResponse.nonVotingMember).toBe(false); + + const nonVotingLeadResponse = acmOrg.leads.find( + (lead: any) => lead.username === "nonvoting@illinois.edu", + ); + expect(nonVotingLeadResponse.nonVotingMember).toBe(true); + + const defaultLeadResponse = acmOrg.leads.find( + (lead: any) => lead.username === "default@illinois.edu", + ); + expect(defaultLeadResponse.nonVotingMember).toBe(false); + }); }); describe("GET /organizations/:orgId - Get specific organization", () => {