From 5e794cf4f1e2e097725ba6216ea6e0e7733a3b04 Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Mon, 23 Jun 2025 20:30:22 -0400 Subject: [PATCH 1/8] add an optional call to action button for email notifications --- src/api/routes/roomRequests.ts | 12 ++++++++++-- src/api/routes/stripe.ts | 6 +++++- src/api/sqs/handlers/templates/notification.ts | 18 ++++++++++++++++++ src/common/types/sqsMessage.ts | 4 ++++ 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/api/routes/roomRequests.ts b/src/api/routes/roomRequests.ts index 8195d1e3..e77197ee 100644 --- a/src/api/routes/roomRequests.ts +++ b/src/api/routes/roomRequests.ts @@ -139,7 +139,11 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => { payload: { to: [originalRequestor], subject: "Room Reservation Request Status Change", - content: `Your Room Reservation Request has been been moved to status "${formatStatus(request.body.status)}". Please visit ${fastify.environmentConfig.UserFacingUrl}/roomRequests/${semesterId}/${requestId} to view details.`, + content: `Your Room Reservation Request has been been moved to status "${formatStatus(request.body.status)}". Please visit the management portal for more details.`, + callToActionButton: { + name: "View Room Request", + url: `${fastify.environmentConfig.UserFacingUrl}/roomRequests/${semesterId}/${requestId}`, + }, }, }; if (!fastify.sqsClient) { @@ -353,7 +357,11 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => { payload: { to: [notificationRecipients[fastify.runEnvironment].OfficerBoard], subject: "New Room Reservation Request", - content: `A new room reservation request has been created (${request.body.host} | ${request.body.title}). Please visit ${fastify.environmentConfig.UserFacingUrl}/roomRequests/${request.body.semester}/${requestId} to view details.`, + content: `A new room reservation request has been created (${request.body.host} | ${request.body.title}). Please visit the management portal for more details.`, + callToActionButton: { + name: "View Room Request", + url: `${fastify.environmentConfig.UserFacingUrl}/roomRequests/${request.body.semester}/${requestId}`, + }, }, }; if (!fastify.sqsClient) { diff --git a/src/api/routes/stripe.ts b/src/api/routes/stripe.ts index 21b38308..ee5ce58f 100644 --- a/src/api/routes/stripe.ts +++ b/src/api/routes/stripe.ts @@ -17,7 +17,7 @@ import { StripeLinkCreateParams, } from "api/functions/stripe.js"; import { getSecretValue } from "api/plugins/auth.js"; -import { genericConfig } from "common/config.js"; +import { environmentConfig, genericConfig } from "common/config.js"; import { BaseError, DatabaseFetchError, @@ -408,6 +408,10 @@ const stripeRoutes: FastifyPluginAsync = async (fastify, _options) => { to: [unmarshalledEntry.userId], subject: `Payment Recieved for Invoice ${unmarshalledEntry.invoiceId}`, content: `ACM @ UIUC has received ${paidInFull ? "full" : "partial"} payment for Invoice ${unmarshalledEntry.invoiceId} (${withCurrency} by ${name}, ${email}).\n\nPlease contact Officer Board with any questions.`, + callToActionButton: { + name: "View Your Stripe Links", + url: `${fastify.environmentConfig.UserFacingUrl}/stripe`, + }, }, }; if (!fastify.sqsClient) { diff --git a/src/api/sqs/handlers/templates/notification.ts b/src/api/sqs/handlers/templates/notification.ts index 7cb3ea3d..f2af331b 100644 --- a/src/api/sqs/handlers/templates/notification.ts +++ b/src/api/sqs/handlers/templates/notification.ts @@ -33,6 +33,24 @@ const template = /*html*/ ` {{nl2br content}} + + {{#if callToActionButton}} + + + + + + +
+ + {{callToActionButton.name}} + +
+ + + {{/if}} +

Date: Mon, 23 Jun 2025 20:37:47 -0400 Subject: [PATCH 2/8] fix email text --- src/api/routes/stripe.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/routes/stripe.ts b/src/api/routes/stripe.ts index ee5ce58f..29f0a131 100644 --- a/src/api/routes/stripe.ts +++ b/src/api/routes/stripe.ts @@ -407,7 +407,7 @@ const stripeRoutes: FastifyPluginAsync = async (fastify, _options) => { payload: { to: [unmarshalledEntry.userId], subject: `Payment Recieved for Invoice ${unmarshalledEntry.invoiceId}`, - content: `ACM @ UIUC has received ${paidInFull ? "full" : "partial"} payment for Invoice ${unmarshalledEntry.invoiceId} (${withCurrency} by ${name}, ${email}).\n\nPlease contact Officer Board with any questions.`, + content: `ACM @ UIUC has received ${paidInFull ? "full" : "partial"} payment for Invoice ${unmarshalledEntry.invoiceId} (${withCurrency} paid by ${name}, ${email}).\n\nPlease contact Officer Board with any questions.`, callToActionButton: { name: "View Your Stripe Links", url: `${fastify.environmentConfig.UserFacingUrl}/stripe`, From 16f16779ee579cf15b330ce9052f3c20c5056752 Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Mon, 23 Jun 2025 21:17:15 -0400 Subject: [PATCH 3/8] send emails on sensitive actions --- src/api/functions/entraId.ts | 52 ++++++++++++++++++++++ src/api/routes/apiKey.ts | 82 ++++++++++++++++++++++++++++++++++ src/api/routes/iam.ts | 85 ++++++++++++++++++++++++++++++++++++ src/common/types/iam.ts | 7 +++ 4 files changed, 226 insertions(+) diff --git a/src/api/functions/entraId.ts b/src/api/functions/entraId.ts index 0aa45327..098673dc 100644 --- a/src/api/functions/entraId.ts +++ b/src/api/functions/entraId.ts @@ -21,6 +21,7 @@ import { ConfidentialClientApplication } from "@azure/msal-node"; import { getItemFromCache, insertItemIntoCache } from "./cache.js"; import { EntraGroupActions, + EntraGroupMetadata, EntraInvitationResponse, ProfilePatchRequest, } from "../../common/types/iam.js"; @@ -553,3 +554,54 @@ export async function listGroupIDsByEmail( }); } } + +/** + * Retrieves metadata for a specific Entra ID group. + * @param token - Entra ID token authorized to take this action. + * @param groupId - The group ID to fetch metadata for. + * @throws {EntraGroupError} If fetching the group metadata fails. + * @returns {Promise} The group's metadata. + */ +export async function getGroupMetadata( + token: string, + groupId: string, +): Promise { + if (!validateGroupId(groupId)) { + throw new EntraGroupError({ + message: "Invalid group ID format", + group: groupId, + }); + } + try { + const url = `https://graph.microsoft.com/v1.0/groups/${groupId}?$select=id,displayName,mail,description`; + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorData = (await response.json()) as { + error?: { message?: string }; + }; + throw new EntraGroupError({ + message: errorData?.error?.message ?? response.statusText, + group: groupId, + }); + } + + const data = (await response.json()) as EntraGroupMetadata; + return data; + } catch (error) { + if (error instanceof EntraGroupError) { + throw error; + } + + throw new EntraGroupError({ + message: error instanceof Error ? error.message : String(error), + group: groupId, + }); + } +} diff --git a/src/api/routes/apiKey.ts b/src/api/routes/apiKey.ts index f84b0aab..93dc5334 100644 --- a/src/api/routes/apiKey.ts +++ b/src/api/routes/apiKey.ts @@ -22,6 +22,8 @@ import { ValidationError, } from "common/errors/index.js"; import { z } from "zod"; +import { AvailableSQSFunctions, SQSPayload } from "common/types/sqsMessage.js"; +import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs"; const apiKeyRoute: FastifyPluginAsync = async (fastify, _options) => { await fastify.register(rateLimiter, { @@ -86,6 +88,47 @@ const apiKeyRoute: FastifyPluginAsync = async (fastify, _options) => { message: "Could not create API key.", }); } + request.log.debug("Constructing SQS payload to send email notification."); + const sqsPayload: SQSPayload = { + function: AvailableSQSFunctions.EmailNotifications, + metadata: { + initiator: request.username!, + reqId: request.id, + }, + payload: { + to: [request.username!], + subject: "Important: ACM @ UIUC API Key Created", + content: ` +This email confirms that an API key for the ACM @ UIUC API has been generated from your account. + +Key ID: acmuiuc_${keyId} + +IP address: ${request.ip}. + +Roles: ${roles.join(", ")}. + +If you did not create this API key, please secure your account and notify the ACM Infrastructure team. + `, + callToActionButton: { + name: "View API Keys", + url: `${fastify.environmentConfig.UserFacingUrl}/apiKeys`, + }, + }, + }; + if (!fastify.sqsClient) { + fastify.sqsClient = new SQSClient({ + region: genericConfig.AwsRegion, + }); + } + const result = await fastify.sqsClient.send( + new SendMessageCommand({ + QueueUrl: fastify.environmentConfig.SqsQueueUrl, + MessageBody: JSON.stringify(sqsPayload), + }), + ); + if (result.MessageId) { + request.log.info(`Queued notification with ID ${result.MessageId}.`); + } return reply.status(201).send({ apiKey, expiresAt, @@ -149,6 +192,45 @@ const apiKeyRoute: FastifyPluginAsync = async (fastify, _options) => { message: "Could not delete API key.", }); } + request.log.debug("Constructing SQS payload to send email notification."); + const sqsPayload: SQSPayload = { + function: AvailableSQSFunctions.EmailNotifications, + metadata: { + initiator: request.username!, + reqId: request.id, + }, + payload: { + to: [request.username!], + subject: "Important: ACM @ UIUC API Key Deleted", + content: ` +This email confirms that an API key for the ACM @ UIUC API has been deleted from your account. + +Key ID: acmuiuc_${keyId} + +IP address: ${request.ip}. + +If you did not delete this API key, please secure your account and notify the ACM Infrastructure team. + `, + callToActionButton: { + name: "View API Keys", + url: `${fastify.environmentConfig.UserFacingUrl}/apiKeys`, + }, + }, + }; + if (!fastify.sqsClient) { + fastify.sqsClient = new SQSClient({ + region: genericConfig.AwsRegion, + }); + } + const result = await fastify.sqsClient.send( + new SendMessageCommand({ + QueueUrl: fastify.environmentConfig.SqsQueueUrl, + MessageBody: JSON.stringify(sqsPayload), + }), + ); + if (result.MessageId) { + request.log.info(`Queued notification with ID ${result.MessageId}.`); + } return reply.status(204).send(); }, ); diff --git a/src/api/routes/iam.ts b/src/api/routes/iam.ts index 3ac90440..e8d7d804 100644 --- a/src/api/routes/iam.ts +++ b/src/api/routes/iam.ts @@ -3,6 +3,7 @@ import { AppRoles } from "../../common/roles.js"; import { addToTenant, getEntraIdToken, + getGroupMetadata, listGroupMembers, modifyGroup, patchUserProfile, @@ -39,6 +40,9 @@ import { Modules } from "common/modules.js"; import { groupId, withRoles, withTags } from "api/components/index.js"; import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi"; import { z } from "zod"; +import { AvailableSQSFunctions, SQSPayload } from "common/types/sqsMessage.js"; +import { SendMessageBatchCommand, SQSClient } from "@aws-sdk/client-sqs"; +import { v4 as uuidv4 } from "uuid"; const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { const getAuthorizedClients = async () => { @@ -305,6 +309,7 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { await getAuthorizedClients(), fastify.environmentConfig.AadValidClientId, ); + const groupMetadataPromise = getGroupMetadata(entraIdToken, groupId); const addResults = await Promise.allSettled( request.body.add.map((email) => modifyGroup( @@ -327,15 +332,19 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { ), ), ); + const groupMetadata = await groupMetadataPromise; const response: Record[]> = { success: [], failure: [], }; const logPromises = []; + const addedEmails = []; + const removedEmails = []; for (let i = 0; i < addResults.length; i++) { const result = addResults[i]; if (result.status === "fulfilled") { response.success.push({ email: request.body.add[i] }); + addedEmails.push(request.body.add[i]); logPromises.push( createAuditLogEntry({ dynamoClient: fastify.dynamoClient, @@ -378,6 +387,7 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { const result = removeResults[i]; if (result.status === "fulfilled") { response.success.push({ email: request.body.remove[i] }); + removedEmails.push(request.body.remove[i]); logPromises.push( createAuditLogEntry({ dynamoClient: fastify.dynamoClient, @@ -416,6 +426,81 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { } } } + const sqsAddedPayloads = addedEmails.map((x) => { + return { + function: AvailableSQSFunctions.EmailNotifications, + metadata: { + initiator: request.username!, + reqId: request.id, + }, + payload: { + to: [x], + subject: "You have been added to an access group", + content: ` +Hello, + +We're letting you know that you have been added to the "${groupMetadata.displayName}" access group by ${request.username}. Changes may take up to 2 hours to reflect in all systems. + +No action is required from you at this time. + `, + }, + }; + }); + const sqsRemovedPayloads = removedEmails.map((x) => { + return { + function: AvailableSQSFunctions.EmailNotifications, + metadata: { + initiator: request.username!, + reqId: request.id, + }, + payload: { + to: [x], + subject: "You have been removed from an access group", + content: ` +Hello, + +We're letting you know that you have been removed from the "${groupMetadata.displayName}" access group by ${request.username}. + +No action is required from you at this time. + `, + }, + }; + }); + if (!fastify.sqsClient) { + fastify.sqsClient = new SQSClient({ + region: genericConfig.AwsRegion, + }); + } + if (sqsAddedPayloads.length > 0) { + request.log.debug("Sending added emails"); + const addedQueued = await fastify.sqsClient.send( + new SendMessageBatchCommand({ + QueueUrl: fastify.environmentConfig.SqsQueueUrl, + Entries: sqsAddedPayloads.map((x) => ({ + Id: uuidv4(), + MessageBody: JSON.stringify(x), + })), + }), + ); + request.log.info( + `Sent added emails, queue ID ${addedQueued.$metadata.requestId}`, + ); + } + if (sqsRemovedPayloads.length > 0) { + request.log.debug("Sending removed emails"); + const removedQueued = await fastify.sqsClient.send( + new SendMessageBatchCommand({ + QueueUrl: fastify.environmentConfig.SqsQueueUrl, + Entries: sqsRemovedPayloads.map((x) => ({ + Id: uuidv4(), + MessageBody: JSON.stringify(x), + })), + }), + ); + request.log.info( + `Sent removed emails, queue ID ${removedQueued.$metadata.requestId}`, + ); + } await Promise.allSettled(logPromises); reply.status(202).send(response); }, diff --git a/src/common/types/iam.ts b/src/common/types/iam.ts index b9dda214..331ffa9e 100644 --- a/src/common/types/iam.ts +++ b/src/common/types/iam.ts @@ -6,6 +6,13 @@ export enum EntraGroupActions { REMOVE, } +export interface EntraGroupMetadata { + id: string; + displayName: string; + mail: string | null; + description: string | null; +} + export interface EntraInvitationResponse { status: number; data?: Record; From 6ac088281a0265d3e88b203db42f5cc123bfda70 Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Mon, 23 Jun 2025 21:30:20 -0400 Subject: [PATCH 4/8] fix unit tests --- tests/unit/apiKey.test.ts | 20 ++++++++++++++++++- tests/unit/entraGroupManagement.test.ts | 26 ++++++++++++++++++++++--- tests/unit/vitest.config.ts | 2 +- 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/tests/unit/apiKey.test.ts b/tests/unit/apiKey.test.ts index 3858ffbb..f3a5c381 100644 --- a/tests/unit/apiKey.test.ts +++ b/tests/unit/apiKey.test.ts @@ -12,6 +12,8 @@ import { } from "@aws-sdk/client-dynamodb"; import { AppRoles } from "../../src/common/roles.js"; import { createApiKey } from "../../src/api/functions/apiKey.js"; +import { randomUUID } from "crypto"; +import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs"; // Mock the createApiKey function vi.mock("../../src/api/functions/apiKey.js", () => { @@ -28,6 +30,7 @@ vi.mock("../../src/api/functions/apiKey.js", () => { // Mock DynamoDB client const dynamoMock = mockClient(DynamoDBClient); +const sqsMock = mockClient(SQSClient); const jwt_secret = secretObject["jwt_key"]; vi.stubEnv("JwtSigningKey", jwt_secret); @@ -37,6 +40,7 @@ const app = await init(); describe("API Key Route Tests", () => { beforeEach(() => { dynamoMock.reset(); + sqsMock.reset(); vi.clearAllMocks(); dynamoMock.on(TransactWriteItemsCommand).resolves({}); @@ -61,6 +65,8 @@ describe("API Key Route Tests", () => { describe("Create API Key", () => { test("Should create an API key successfully", async () => { + const queueId = randomUUID(); + sqsMock.on(SendMessageCommand).resolves({ MessageId: queueId }); const testJwt = createJwt(); await app.ready(); @@ -79,10 +85,13 @@ describe("API Key Route Tests", () => { expect(response.body.apiKey).toBe("acmuiuc_test123_abcdefg12345"); expect(createApiKey).toHaveBeenCalledTimes(1); expect(dynamoMock.calls()).toHaveLength(1); + expect(sqsMock.calls()).toHaveLength(1); }); test("Should create an API key with expiration", async () => { const testJwt = createJwt(); + const queueId = randomUUID(); + sqsMock.on(SendMessageCommand).resolves({ MessageId: queueId }); await app.ready(); const expiryTime = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now @@ -104,9 +113,11 @@ describe("API Key Route Tests", () => { expect(response.body.expiresAt).toBe(expiryTime); expect(createApiKey).toHaveBeenCalledTimes(1); expect(dynamoMock.calls()).toHaveLength(1); + expect(sqsMock.calls()).toHaveLength(1); }); test("Should not create an API key for invalid API key roles", async () => { + sqsMock.on(SendMessageCommand).rejects({}); const testJwt = createJwt(); await app.ready(); @@ -131,6 +142,7 @@ describe("API Key Route Tests", () => { expect(createApiKey).toHaveBeenCalledTimes(0); expect(dynamoMock.calls()).toHaveLength(0); + expect(sqsMock.calls()).toHaveLength(0); }); test("Should handle DynamoDB insertion error", async () => { @@ -138,7 +150,7 @@ describe("API Key Route Tests", () => { dynamoMock .on(TransactWriteItemsCommand) .rejects(new Error("DynamoDB error")); - + sqsMock.on(SendMessageCommand).rejects({}); const testJwt = createJwt(); await app.ready(); @@ -157,6 +169,7 @@ describe("API Key Route Tests", () => { expect(response.body.message).toBe("Could not create API key."); expect(createApiKey).toHaveBeenCalledTimes(1); expect(dynamoMock.calls()).toHaveLength(1); + expect(sqsMock.calls()).toHaveLength(0); }); test("Should require authorization", async () => { @@ -177,6 +190,8 @@ describe("API Key Route Tests", () => { describe("Delete API Key", () => { test("Should delete an API key successfully", async () => { + const queueId = randomUUID(); + sqsMock.on(SendMessageCommand).resolves({ MessageId: queueId }); const testJwt = createJwt(); await app.ready(); @@ -212,10 +227,12 @@ describe("API Key Route Tests", () => { expect(response.body).toHaveProperty("message"); expect(response.body.message).toBe("Key does not exist."); expect(dynamoMock.calls()).toHaveLength(1); + expect(sqsMock.calls()).toHaveLength(0); }); test("Should handle DynamoDB deletion error", async () => { // Mock the DynamoDB client to throw a generic error + sqsMock.on(SendMessageCommand).rejects(); dynamoMock .on(TransactWriteItemsCommand) .rejects(new Error("DynamoDB error")); @@ -233,6 +250,7 @@ describe("API Key Route Tests", () => { expect(response.body).toHaveProperty("message"); expect(response.body.message).toBe("Could not delete API key."); expect(dynamoMock.calls()).toHaveLength(1); + expect(sqsMock.calls()).toHaveLength(0); }); test("Should require authentication", async () => { diff --git a/tests/unit/entraGroupManagement.test.ts b/tests/unit/entraGroupManagement.test.ts index 534fe0cc..a95d354a 100644 --- a/tests/unit/entraGroupManagement.test.ts +++ b/tests/unit/entraGroupManagement.test.ts @@ -4,6 +4,11 @@ import { createJwt } from "./auth.test.js"; import supertest from "supertest"; import { describe } from "node:test"; import { EntraGroupError } from "../../src/common/errors/index.js"; +import { + SendMessageBatchCommand, + SendMessageCommand, + SQSClient, +} from "@aws-sdk/client-sqs"; // Mock required dependencies - their real impl's are defined in the beforeEach section. vi.mock("../../src/api/functions/entraId.js", () => { @@ -21,9 +26,14 @@ vi.mock("../../src/api/functions/entraId.js", () => { listGroupMembers: vi.fn().mockImplementation(async () => { return ""; }), + getGroupMetadata: vi.fn().mockImplementation(async () => { + return { id: "abc123", displayName: "thing" }; + }), }; }); +const sqsMock = mockClient(SQSClient); + import { modifyGroup, listGroupMembers, @@ -31,15 +41,23 @@ import { resolveEmailToOid, } from "../../src/api/functions/entraId.js"; import { EntraGroupActions } from "../../src/common/types/iam.js"; +import { randomUUID } from "crypto"; +import { mockClient } from "aws-sdk-client-mock"; +import { V } from "vitest/dist/chunks/reporters.d.79o4mouw.js"; const app = await init(); describe("Test Modify Group and List Group Routes", () => { beforeEach(() => { (app as any).nodeCache.flushAll(); + sqsMock.reset(); vi.clearAllMocks(); }); - test("Modify group: Add and remove members", async () => { + test.only("Modify group: Add and remove members", async () => { + const queueId = randomUUID(); + sqsMock + .on(SendMessageBatchCommand) + .resolves({ $metadata: { requestId: queueId } }); const testJwt = createJwt(); await app.ready(); @@ -50,7 +68,7 @@ describe("Test Modify Group and List Group Routes", () => { add: ["validuser1@illinois.edu"], remove: ["validuser2@illinois.edu"], }); - + sqsMock.on(SendMessageCommand).resolves({}); expect(response.statusCode).toBe(202); expect(modifyGroup).toHaveBeenCalledTimes(2); expect(modifyGroup).toHaveBeenNthCalledWith( @@ -75,12 +93,13 @@ describe("Test Modify Group and List Group Routes", () => { { email: "validuser2@illinois.edu" }, ]); expect(response.body.failure).toEqual([]); + expect(sqsMock.calls()).toHaveLength(2); }); test("Modify group: Fail for invalid email domain", async () => { const testJwt = createJwt(); await app.ready(); - + sqsMock.on(SendMessageBatchCommand).rejects(); const response = await supertest(app.server) .patch("/api/v1/iam/groups/test-group-id") .set("authorization", `Bearer ${testJwt}`) @@ -99,6 +118,7 @@ describe("Test Modify Group and List Group Routes", () => { "User's domain must be illinois.edu to be added or removed from the group.", }, ]); + expect(sqsMock.calls()).toHaveLength(0); }); test("List group members: Happy path", async () => { diff --git a/tests/unit/vitest.config.ts b/tests/unit/vitest.config.ts index 30045a11..30107126 100644 --- a/tests/unit/vitest.config.ts +++ b/tests/unit/vitest.config.ts @@ -13,7 +13,7 @@ export default defineConfig({ include: ["src/api/**/*.ts", "src/common/**/*.ts"], exclude: ["src/api/lambda.ts", "src/api/sqs/handlers/templates/*.ts"], thresholds: { - statements: 55, + statements: 54, functions: 66, lines: 54, }, From c386d9158edceb29a05f427009898ad08e88c0f2 Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Mon, 23 Jun 2025 21:37:14 -0400 Subject: [PATCH 5/8] remove .only from tests --- tests/unit/entraGroupManagement.test.ts | 2 +- tests/unit/vitest.config.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/entraGroupManagement.test.ts b/tests/unit/entraGroupManagement.test.ts index a95d354a..6eb55af0 100644 --- a/tests/unit/entraGroupManagement.test.ts +++ b/tests/unit/entraGroupManagement.test.ts @@ -53,7 +53,7 @@ describe("Test Modify Group and List Group Routes", () => { vi.clearAllMocks(); }); - test.only("Modify group: Add and remove members", async () => { + test("Modify group: Add and remove members", async () => { const queueId = randomUUID(); sqsMock .on(SendMessageBatchCommand) diff --git a/tests/unit/vitest.config.ts b/tests/unit/vitest.config.ts index 30107126..2a4193da 100644 --- a/tests/unit/vitest.config.ts +++ b/tests/unit/vitest.config.ts @@ -13,9 +13,9 @@ export default defineConfig({ include: ["src/api/**/*.ts", "src/common/**/*.ts"], exclude: ["src/api/lambda.ts", "src/api/sqs/handlers/templates/*.ts"], thresholds: { - statements: 54, + statements: 55, functions: 66, - lines: 54, + lines: 55, }, }, }, From 6c84515b56b32f6b8f271062cb6bf1e2aaac3dc7 Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Mon, 23 Jun 2025 21:51:11 -0400 Subject: [PATCH 6/8] chunk commands --- src/api/routes/iam.ts | 119 ++++++++++++++++++++++++------------------ 1 file changed, 67 insertions(+), 52 deletions(-) diff --git a/src/api/routes/iam.ts b/src/api/routes/iam.ts index e8d7d804..fc14eb38 100644 --- a/src/api/routes/iam.ts +++ b/src/api/routes/iam.ts @@ -43,6 +43,7 @@ import { z } from "zod"; import { AvailableSQSFunctions, SQSPayload } from "common/types/sqsMessage.js"; import { SendMessageBatchCommand, SQSClient } from "@aws-sdk/client-sqs"; import { v4 as uuidv4 } from "uuid"; +import { randomUUID } from "crypto"; const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { const getAuthorizedClients = async () => { @@ -426,46 +427,50 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { } } } - const sqsAddedPayloads = addedEmails.map((x) => { - return { - function: AvailableSQSFunctions.EmailNotifications, - metadata: { - initiator: request.username!, - reqId: request.id, - }, - payload: { - to: [x], - subject: "You have been added to an access group", - content: ` + const sqsAddedPayloads = addedEmails + .filter((x) => !!x) + .map((x) => { + return { + function: AvailableSQSFunctions.EmailNotifications, + metadata: { + initiator: request.username!, + reqId: request.id, + }, + payload: { + to: [x], + subject: "You have been added to an access group", + content: ` Hello, We're letting you know that you have been added to the "${groupMetadata.displayName}" access group by ${request.username}. Changes may take up to 2 hours to reflect in all systems. No action is required from you at this time. `, - }, - }; - }); - const sqsRemovedPayloads = removedEmails.map((x) => { - return { - function: AvailableSQSFunctions.EmailNotifications, - metadata: { - initiator: request.username!, - reqId: request.id, - }, - payload: { - to: [x], - subject: "You have been removed from an access group", - content: ` + }, + }; + }); + const sqsRemovedPayloads = removedEmails + .filter((x) => !!x) + .map((x) => { + return { + function: AvailableSQSFunctions.EmailNotifications, + metadata: { + initiator: request.username!, + reqId: request.id, + }, + payload: { + to: [x], + subject: "You have been removed from an access group", + content: ` Hello, We're letting you know that you have been removed from the "${groupMetadata.displayName}" access group by ${request.username}. No action is required from you at this time. `, - }, - }; - }); + }, + }; + }); if (!fastify.sqsClient) { fastify.sqsClient = new SQSClient({ region: genericConfig.AwsRegion, @@ -473,33 +478,43 @@ No action is required from you at this time. } if (sqsAddedPayloads.length > 0) { request.log.debug("Sending added emails"); - const addedQueued = await fastify.sqsClient.send( - new SendMessageBatchCommand({ - QueueUrl: fastify.environmentConfig.SqsQueueUrl, - Entries: sqsAddedPayloads.map((x) => ({ - Id: uuidv4(), - MessageBody: JSON.stringify(x), - })), - }), - ); - request.log.info( - `Sent added emails, queue ID ${addedQueued.$metadata.requestId}`, - ); + let chunkId = 0; + for (let i = 0; i < sqsAddedPayloads.length; i += 10) { + chunkId += 1; + const chunk = sqsAddedPayloads.slice(i, i + 10); + const removedQueued = await fastify.sqsClient.send( + new SendMessageBatchCommand({ + QueueUrl: fastify.environmentConfig.SqsQueueUrl, + Entries: chunk.map((x) => ({ + Id: randomUUID(), + MessageBody: JSON.stringify(x), + })), + }), + ); + request.log.info( + `Sent added emails chunk ${chunkId}, queue ID ${removedQueued.$metadata.requestId}`, + ); + } } if (sqsRemovedPayloads.length > 0) { request.log.debug("Sending removed emails"); - const removedQueued = await fastify.sqsClient.send( - new SendMessageBatchCommand({ - QueueUrl: fastify.environmentConfig.SqsQueueUrl, - Entries: sqsRemovedPayloads.map((x) => ({ - Id: uuidv4(), - MessageBody: JSON.stringify(x), - })), - }), - ); - request.log.info( - `Sent removed emails, queue ID ${removedQueued.$metadata.requestId}`, - ); + let chunkId = 0; + for (let i = 0; i < sqsRemovedPayloads.length; i += 10) { + chunkId += 1; + const chunk = sqsRemovedPayloads.slice(i, i + 10); + const removedQueued = await fastify.sqsClient.send( + new SendMessageBatchCommand({ + QueueUrl: fastify.environmentConfig.SqsQueueUrl, + Entries: chunk.map((x) => ({ + Id: randomUUID(), + MessageBody: JSON.stringify(x), + })), + }), + ); + request.log.info( + `Sent removed emails chunk ${chunkId}, queue ID ${removedQueued.$metadata.requestId}`, + ); + } } await Promise.allSettled(logPromises); reply.status(202).send(response); From f4a3cbed17f8cb0fa34cd3b4a125955f55554cd4 Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Mon, 23 Jun 2025 21:58:48 -0400 Subject: [PATCH 7/8] add description to email --- src/api/routes/apiKey.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/api/routes/apiKey.ts b/src/api/routes/apiKey.ts index 93dc5334..a3f3e743 100644 --- a/src/api/routes/apiKey.ts +++ b/src/api/routes/apiKey.ts @@ -103,6 +103,8 @@ This email confirms that an API key for the ACM @ UIUC API has been generated fr Key ID: acmuiuc_${keyId} +Key Description: ${description} + IP address: ${request.ip}. Roles: ${roles.join(", ")}. From 93d392b74acfa904a93b966bd6f56f383aebb64f Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Mon, 23 Jun 2025 22:00:05 -0400 Subject: [PATCH 8/8] change wording on email --- src/api/routes/apiKey.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/api/routes/apiKey.ts b/src/api/routes/apiKey.ts index a3f3e743..78d40da5 100644 --- a/src/api/routes/apiKey.ts +++ b/src/api/routes/apiKey.ts @@ -97,9 +97,9 @@ const apiKeyRoute: FastifyPluginAsync = async (fastify, _options) => { }, payload: { to: [request.username!], - subject: "Important: ACM @ UIUC API Key Created", + subject: "Important: API Key Created", content: ` -This email confirms that an API key for the ACM @ UIUC API has been generated from your account. +This email confirms that an API key for the Core API has been generated from your account. Key ID: acmuiuc_${keyId} @@ -203,9 +203,9 @@ If you did not create this API key, please secure your account and notify the AC }, payload: { to: [request.username!], - subject: "Important: ACM @ UIUC API Key Deleted", + subject: "Important: API Key Deleted", content: ` -This email confirms that an API key for the ACM @ UIUC API has been deleted from your account. +This email confirms that an API key for the Core API has been deleted from your account. Key ID: acmuiuc_${keyId}