diff --git a/src/api/index.ts b/src/api/index.ts index bb6e78ac..483bcce4 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -62,6 +62,7 @@ import { docsHtml, securitySchemes } from "./docs.js"; import syncIdentityPlugin from "./routes/syncIdentity.js"; import { createRedisModule } from "./redis.js"; import userRoute from "./routes/user.js"; +import rsvpRoutes from "./routes/rsvp.js"; /** END ROUTES */ export const instanceId = randomUUID(); @@ -377,6 +378,9 @@ Otherwise, email [infra@acm.illinois.edu](mailto:infra@acm.illinois.edu) for sup api.register(apiKeyRoute, { prefix: "/apiKey" }); api.register(clearSessionRoute, { prefix: "/clearSession" }); api.register(userRoute, { prefix: "/users" }); + if (app.runEnvironment === "dev") { + api.register(rsvpRoutes, { prefix: "/rsvp" }); + } if (app.runEnvironment === "dev") { api.register(vendingPlugin, { prefix: "/vending" }); } diff --git a/src/api/routes/rsvp.ts b/src/api/routes/rsvp.ts new file mode 100644 index 00000000..b1ebb107 --- /dev/null +++ b/src/api/routes/rsvp.ts @@ -0,0 +1,126 @@ +import { FastifyPluginAsync } from "fastify"; +import rateLimiter from "api/plugins/rateLimiter.js"; +import { withRoles, withTags } from "api/components/index.js"; +import { QueryCommand, PutItemCommand } from "@aws-sdk/client-dynamodb"; +import { unmarshall, marshall } from "@aws-sdk/util-dynamodb"; +import { + DatabaseFetchError, + UnauthenticatedError, + UnauthorizedError, + ValidationError, +} from "common/errors/index.js"; +import * as z from "zod/v4"; +import { verifyUiucAccessToken } from "api/functions/uin.js"; +import { checkPaidMembership } from "api/functions/membership.js"; +import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi"; +import { genericConfig } from "common/config.js"; +import { AppRoles } from "common/roles.js"; + +const rsvpRoutes: FastifyPluginAsync = async (fastify, _options) => { + await fastify.register(rateLimiter, { + limit: 30, + duration: 30, + rateLimitIdentifier: "rsvp", + }); + fastify.withTypeProvider().post( + "/:orgId/event/:eventId", + { + schema: withTags(["RSVP"], { + summary: "Submit an RSVP for an event.", + params: z.object({ + eventId: z.string().min(1).meta({ + description: "The previously-created event ID in the events API.", + }), + orgId: z.string().min(1).meta({ + description: "The organization ID the event belongs to.", + }), + }), + headers: z.object({ + "x-uiuc-token": z.jwt().min(1).meta({ + description: + "An access token for the user in the UIUC Entra ID tenant.", + }), + }), + }), + }, + async (request, reply) => { + const accessToken = request.headers["x-uiuc-token"]; + const verifiedData = await verifyUiucAccessToken({ + accessToken, + logger: request.log, + }); + const { userPrincipalName: upn, givenName, surname } = verifiedData; + const netId = upn.replace("@illinois.edu", ""); + if (netId.includes("@")) { + request.log.error( + `Found UPN ${upn} which cannot be turned into NetID via simple replacement.`, + ); + throw new ValidationError({ + message: "ID token could not be parsed.", + }); + } + const isPaidMember = await checkPaidMembership({ + netId, + dynamoClient: fastify.dynamoClient, + redisClient: fastify.redisClient, + logger: request.log, + }); + const entry = { + partitionKey: `${request.params.eventId}#${upn}`, + eventId: request.params.eventId, + userId: upn, + isPaidMember, + createdAt: "", + }; + const putCommand = new PutItemCommand({ + TableName: genericConfig.RSVPDynamoTableName, + Item: marshall(entry), + }); + await fastify.dynamoClient.send(putCommand); + return reply.status(201).send(entry); + }, + ); + fastify.withTypeProvider().get( + "/:orgId/event/:eventId", + { + schema: withRoles( + [AppRoles.VIEW_RSVPS], + withTags(["RSVP"], { + summary: "Get all RSVPs for an event.", + params: z.object({ + eventId: z.string().min(1).meta({ + description: "The previously-created event ID in the events API.", + }), + orgId: z.string().min(1).meta({ + description: "The organization ID the event belongs to.", + }), + }), + }), + ), + onRequest: fastify.authorizeFromSchema, + }, + async (request, reply) => { + const command = new QueryCommand({ + TableName: genericConfig.RSVPDynamoTableName, + IndexName: "EventIdIndex", + KeyConditionExpression: "eventId = :eid", + ExpressionAttributeValues: { + ":eid": { S: request.params.eventId }, + }, + }); + const response = await fastify.dynamoClient.send(command); + if (!response || !response.Items) { + throw new DatabaseFetchError({ + message: "Failed to get all member lists.", + }); + } + const rsvps = response.Items.map((x) => unmarshall(x)); + const uniqueRsvps = [ + ...new Map(rsvps.map((item) => [item.userId, item])).values(), + ]; + return reply.send(uniqueRsvps); + }, + ); +}; + +export default rsvpRoutes; diff --git a/src/common/config.ts b/src/common/config.ts index 1b1fe59e..0f093ffc 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -38,6 +38,7 @@ export type ConfigType = { export type GenericConfigType = { EventsDynamoTableName: string; + RSVPDynamoTableName: string; CacheDynamoTableName: string; LinkryDynamoTableName: string; StripeLinksDynamoTableName: string; @@ -84,6 +85,7 @@ export const commChairsGroupId = "105e7d32-7289-435e-a67a-552c7f215507"; const genericConfig: GenericConfigType = { EventsDynamoTableName: "infra-core-api-events", + RSVPDynamoTableName: "infra-core-api-events-rsvp", StripeLinksDynamoTableName: "infra-core-api-stripe-links", StripePaymentsDynamoTableName: "infra-core-api-stripe-payments", CacheDynamoTableName: "infra-core-api-cache", diff --git a/src/common/roles.ts b/src/common/roles.ts index b6417017..72564395 100644 --- a/src/common/roles.ts +++ b/src/common/roles.ts @@ -7,6 +7,8 @@ export const META_ROLE_PREFIX = "__metaRole:" export enum BaseRoles { EVENTS_MANAGER = "manage:events", + RSVPS_MANAGER = "manage:rsvps", + VIEW_RSVPS = "view:rsvps", TICKETS_SCANNER = "scan:tickets", TICKETS_MANAGER = "manage:tickets", IAM_ADMIN = "admin:iam", @@ -47,6 +49,8 @@ export const AppRoleHumanMapper: Record = { [AppRoles.EVENTS_MANAGER]: "Events Manager", [AppRoles.TICKETS_SCANNER]: "Tickets Scanner", [AppRoles.TICKETS_MANAGER]: "Tickets Manager", + [AppRoles.RSVPS_MANAGER]: "RSVPs Manager", + [AppRoles.VIEW_RSVPS]: "RSVPs Viewer", [AppRoles.IAM_ADMIN]: "IAM Admin", [AppRoles.IAM_INVITE_ONLY]: "IAM Inviter", [AppRoles.LINKS_MANAGER]: "Links Manager", diff --git a/src/common/types/rsvp.ts b/src/common/types/rsvp.ts new file mode 100644 index 00000000..51f13c53 --- /dev/null +++ b/src/common/types/rsvp.ts @@ -0,0 +1,8 @@ +import * as z from "zod/v4"; + +export const rsvpItemSchema = z.object({ + eventId: z.string(), + userId: z.string(), + isPaidMember: z.boolean(), + createdAt: z.string(), +}); diff --git a/tests/unit/rsvps.test.ts b/tests/unit/rsvps.test.ts new file mode 100644 index 00000000..1ff3cae6 --- /dev/null +++ b/tests/unit/rsvps.test.ts @@ -0,0 +1,157 @@ +import { expect, test, vi, describe, beforeEach } from "vitest"; +import { + DynamoDBClient, + PutItemCommand, + QueryCommand, +} from "@aws-sdk/client-dynamodb"; +import { marshall } from "@aws-sdk/util-dynamodb"; +import { mockClient } from "aws-sdk-client-mock"; +import init from "../../src/api/index.js"; +import { createJwt } from "./auth.test.js"; +import { testSecretObject } from "./secret.testdata.js"; +import { Redis } from "../../src/api/types.js"; +import { FastifyBaseLogger } from "fastify"; + +const DUMMY_JWT = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; + +vi.mock("../../src/api/functions/uin.js", async () => { + const actual = await vi.importActual("../../src/api/functions/uin.js"); + return { + ...actual, + verifyUiucAccessToken: vi + .fn() + .mockImplementation( + async ({ + token, + logger, + }: { + token: string; + logger: FastifyBaseLogger; + }) => { + if (token === DUMMY_JWT) { + console.log("DUMMY_JWT matched in mock implementation"); + } + return { + userPrincipalName: "jd3@illinois.edu", + givenName: "John", + surname: "Doe", + mail: "johndoe@gmail.com", + }; + }, + ), + }; +}); + +vi.mock("../../src/api/functions/membership.js", async () => { + const actual = await vi.importActual("../../src/api/functions/membership.js"); + return { + ...actual, + checkPaidMembership: vi + .fn() + .mockImplementation( + async ({ + netId, + redisClient, + dynamoClient, + logger, + }: { + netId: string; + redisClient: Redis; + dynamoClient: DynamoDBClient; + logger: FastifyBaseLogger; + }) => { + if (netId === "jd3") { + return true; + } + return false; + }, + ), + }; +}); + +const ddbMock = mockClient(DynamoDBClient); +const jwt_secret = testSecretObject["jwt_key"]; +vi.stubEnv("JwtSigningKey", jwt_secret); + +const app = await init(); + +describe("RSVP API tests", () => { + beforeEach(() => { + ddbMock.reset(); + vi.clearAllMocks(); + }); + + test("Test posting an RSVP for an event", async () => { + ddbMock.on(PutItemCommand).resolves({}); + + const testJwt = createJwt(); + const mockUpn = "jd3@illinois.edu"; + const eventId = "Make Your Own Database"; + const orgId = "SIGDatabase"; + + const response = await app.inject({ + method: "POST", + url: `/api/v1/rsvp/${orgId}/event/${encodeURIComponent(eventId)}`, + headers: { + Authorization: `Bearer ${testJwt}`, + "x-uiuc-token": DUMMY_JWT, + }, + }); + + if (response.statusCode !== 201) { + console.log("Test Failed Response:", response.body); + } + + expect(response.statusCode).toBe(201); + + const body = JSON.parse(response.body); + expect(body.userId).toBe(mockUpn); + expect(body.eventId).toBe(eventId); + expect(body.isPaidMember).toBe(true); + expect(body.partitionKey).toBe(`${eventId}#${mockUpn}`); + + expect(ddbMock.calls()).toHaveLength(1); + const putItemInput = ddbMock.call(0).args[0].input as any; + expect(putItemInput.TableName).toBe("infra-core-api-events-rsvp"); + }); + + test("Test getting RSVPs for an event (Mocking Query Response)", async () => { + const eventId = "Make Your Own Database"; + const orgId = "SIGDatabase"; + const mockRsvps = [ + { + eventId, + userId: "user1@illinois.edu", + isPaidMember: true, + createdAt: "2023-01-01", + }, + { + eventId, + userId: "user2@illinois.edu", + isPaidMember: false, + createdAt: "2023-01-02", + }, + ]; + ddbMock.on(QueryCommand).resolves({ + Items: mockRsvps.map((item) => marshall(item)), + }); + + const adminJwt = await createJwt(); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/rsvp/${orgId}/event/${encodeURIComponent(eventId)}`, + headers: { + Authorization: `Bearer ${adminJwt}`, + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + + expect(body).toHaveLength(2); + expect(body[0].userId).toBe("user1@illinois.edu"); + expect(body[1].userId).toBe("user2@illinois.edu"); + }); +});