diff --git a/src/api/functions/mobileWallet.ts b/src/api/functions/mobileWallet.ts index ffe9221f..c2c0986d 100644 --- a/src/api/functions/mobileWallet.ts +++ b/src/api/functions/mobileWallet.ts @@ -118,7 +118,7 @@ export async function issueAppleWalletMembershipCard( pkpass.backFields.push({ label: "Membership ID", key: "id", value: email }); const buffer = pkpass.getAsBuffer(); logger.info( - { type: "audit", actor: initiator, target: email }, + { type: "audit", module: "mobileWallet", actor: initiator, target: email }, "Created membership verification pass", ); return buffer; diff --git a/src/api/routes/events.ts b/src/api/routes/events.ts index 27367260..f21400a2 100644 --- a/src/api/routes/events.ts +++ b/src/api/routes/events.ts @@ -327,7 +327,12 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => { resource: `/api/v1/events/${entryUUID}`, }); request.log.info( - { type: "audit", actor: request.username, target: entryUUID }, + { + type: "audit", + module: "events", + actor: request.username, + target: entryUUID, + }, `${verb} event "${entryUUID}"`, ); } catch (e: unknown) { @@ -388,7 +393,12 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => { false, ); request.log.info( - { type: "audit", actor: request.username, target: id }, + { + type: "audit", + module: "events", + actor: request.username, + target: id, + }, `deleted event "${id}"`, ); }, diff --git a/src/api/routes/iam.ts b/src/api/routes/iam.ts index 19002441..947479db 100644 --- a/src/api/routes/iam.ts +++ b/src/api/routes/iam.ts @@ -201,7 +201,12 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { } reply.send({ message: "OK" }); request.log.info( - { type: "audit", actor: request.username, target: groupId }, + { + type: "audit", + module: "iam", + actor: request.username, + target: groupId, + }, `set target roles to ${request.body.roles.toString()}`, ); }, @@ -241,13 +246,23 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { const result = results[i]; if (result.status === "fulfilled") { request.log.info( - { type: "audit", actor: request.username, target: emails[i] }, + { + type: "audit", + module: "iam", + actor: request.username, + target: emails[i], + }, "invited user to Entra ID tenant.", ); response.success.push({ email: emails[i] }); } else { request.log.info( - { type: "audit", actor: request.username, target: emails[i] }, + { + type: "audit", + module: "iam", + actor: request.username, + target: emails[i], + }, "failed to invite user to Entra ID tenant.", ); if (result.reason instanceof EntraInvitationError) { @@ -345,6 +360,7 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { request.log.info( { type: "audit", + module: "iam", actor: request.username, target: request.body.add[i], }, @@ -354,6 +370,7 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { request.log.info( { type: "audit", + module: "iam", actor: request.username, target: request.body.add[i], }, @@ -379,6 +396,7 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { request.log.info( { type: "audit", + module: "iam", actor: request.username, target: request.body.remove[i], }, @@ -388,6 +406,7 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { request.log.info( { type: "audit", + module: "iam", actor: request.username, target: request.body.add[i], }, diff --git a/src/api/routes/stripe.ts b/src/api/routes/stripe.ts index 2e942488..12cbb2a3 100644 --- a/src/api/routes/stripe.ts +++ b/src/api/routes/stripe.ts @@ -139,6 +139,7 @@ const stripeRoutes: FastifyPluginAsync = async (fastify, _options) => { request.log.info( { type: "audit", + module: "stripe", actor: request.username, target: `Link ${linkId} | Invoice ${invoiceId}`, }, diff --git a/src/api/routes/tickets.ts b/src/api/routes/tickets.ts index 74783b90..0ad8dd0d 100644 --- a/src/api/routes/tickets.ts +++ b/src/api/routes/tickets.ts @@ -1,6 +1,7 @@ import { FastifyPluginAsync } from "fastify"; import { z } from "zod"; import { + ConditionalCheckFailedException, QueryCommand, ScanCommand, UpdateItemCommand, @@ -300,14 +301,17 @@ const ticketsPlugin: FastifyPluginAsync = async (fastify, _options) => { stripe_pi: { S: ticketId }, }, UpdateExpression: "SET fulfilled = :true_val", - ConditionExpression: "#email = :email_val", + ConditionExpression: + "#email = :email_val AND (attribute_not_exists(fulfilled) OR fulfilled = :false_val) AND (attribute_not_exists(refunded) OR refunded = :false_val)", ExpressionAttributeNames: { "#email": "email", }, ExpressionAttributeValues: { ":true_val": { BOOL: true }, + ":false_val": { BOOL: false }, ":email_val": { S: request.body.email }, }, + ReturnValuesOnConditionCheckFailure: "ALL_OLD", ReturnValues: "ALL_OLD", }); break; @@ -319,12 +323,16 @@ const ticketsPlugin: FastifyPluginAsync = async (fastify, _options) => { ticket_id: { S: ticketId }, }, UpdateExpression: "SET #used = :trueValue", + ConditionExpression: + "(attribute_not_exists(#used) OR #used = :falseValue) AND (attribute_not_exists(refunded) OR refunded = :falseValue)", ExpressionAttributeNames: { "#used": "used", }, ExpressionAttributeValues: { ":trueValue": { BOOL: true }, + ":falseValue": { BOOL: false }, }, + ReturnValuesOnConditionCheckFailure: "ALL_OLD", ReturnValues: "ALL_OLD", }); break; @@ -342,16 +350,6 @@ const ticketsPlugin: FastifyPluginAsync = async (fastify, _options) => { }); } const attributes = unmarshall(ticketEntry.Attributes); - if (attributes["refunded"]) { - throw new TicketNotValidError({ - message: "Ticket was already refunded.", - }); - } - if (attributes["used"] || attributes["fulfilled"]) { - throw new TicketNotValidError({ - message: "Ticket has already been used.", - }); - } if (request.body.type === "ticket") { const rawData = attributes["ticketholder_netid"]; const isEmail = validateEmail(attributes["ticketholder_netid"]); @@ -376,65 +374,41 @@ const ticketsPlugin: FastifyPluginAsync = async (fastify, _options) => { if (e instanceof BaseError) { throw e; } - if (e.name === "ConditionalCheckFailedException") { + if (e instanceof ConditionalCheckFailedException) { + if (e.Item) { + const unmarshalled = unmarshall(e.Item); + if (unmarshalled["fulfilled"] || unmarshalled["used"]) { + throw new TicketNotValidError({ + message: "Ticket has already been used.", + }); + } + if (unmarshalled["refunded"]) { + throw new TicketNotValidError({ + message: "Ticket was already refunded.", + }); + } + } throw new TicketNotFoundError({ - message: "Ticket does not exist", + message: "Ticket does not exist.", }); } throw new DatabaseFetchError({ message: "Could not set ticket to used - database operation failed", }); } - const response = { + reply.send({ valid: true, type: request.body.type, ticketId, purchaserData, - }; - switch (request.body.type) { - case "merch": - ticketId = request.body.stripePi; - command = new UpdateItemCommand({ - TableName: genericConfig.MerchStorePurchasesTableName, - Key: { - stripe_pi: { S: ticketId }, - }, - UpdateExpression: - "SET scannerEmail = :scanner_email, scanISOTimestamp = :scan_time", - ConditionExpression: "email = :email_val", - ExpressionAttributeValues: { - ":scanner_email": { S: request.username }, - ":scan_time": { S: new Date().toISOString() }, - ":email_val": { S: request.body.email }, - }, - }); - break; - - case "ticket": - ticketId = request.body.ticketId; - command = new UpdateItemCommand({ - TableName: genericConfig.TicketPurchasesTableName, - Key: { - ticket_id: { S: ticketId }, - }, - UpdateExpression: - "SET scannerEmail = :scanner_email, scanISOTimestamp = :scan_time", - ExpressionAttributeValues: { - ":scanner_email": { S: request.username }, - ":scan_time": { S: new Date().toISOString() }, - }, - }); - break; - - default: - throw new ValidationError({ - message: `Unknown verification type!`, - }); - } - await fastify.dynamoClient.send(command); - reply.send(response); + }); request.log.info( - { type: "audit", actor: request.username, target: ticketId }, + { + type: "audit", + module: "tickets", + actor: request.username, + target: ticketId, + }, `checked in ticket of type "${request.body.type}" ${request.body.type === "merch" ? `purchased by email ${request.body.email}.` : "."}`, ); }, diff --git a/src/api/sqs/handlers.ts b/src/api/sqs/handlers.ts index f828b327..4cd4b492 100644 --- a/src/api/sqs/handlers.ts +++ b/src/api/sqs/handlers.ts @@ -109,7 +109,12 @@ export const provisionNewMemberHandler: SQSHandlerFunction< }); if (updated) { logger.info( - { type: "audit", actor: metadata.initiator, target: email }, + { + type: "audit", + module: "provisionNewMember", + actor: metadata.initiator, + target: email, + }, "marked user as a paid member.", ); logger.info( diff --git a/tests/unit/tickets.test.ts b/tests/unit/tickets.test.ts index 662c8e06..cff62706 100644 --- a/tests/unit/tickets.test.ts +++ b/tests/unit/tickets.test.ts @@ -1,6 +1,7 @@ import { afterAll, expect, test, beforeEach, vi, describe } from "vitest"; import { AttributeValue, + ConditionalCheckFailedException, DynamoDBClient, QueryCommand, ScanCommand, @@ -204,10 +205,19 @@ describe("Test ticket purchase verification", async () => { }); }); test("Sad path: fulfilling an already-fulfilled ticket item fails", async () => { - ddbMock - .on(UpdateItemCommand) - .resolvesOnce({ Attributes: fulfilledMerchItem1 }) - .resolvesOnce({}); + const conditionalError = new ConditionalCheckFailedException({ + message: "The conditional request failed", + $metadata: {}, + }); + (conditionalError as any).Item = { + ticket_id: { + S: "975b4470cf37d7cf20fd404a711513fd1d1e68259ded27f10727d1384961843d", + }, + used: { BOOL: true }, + refunded: { BOOL: false }, + }; + + ddbMock.on(UpdateItemCommand).rejects(conditionalError); const testJwt = createJwt(); await app.ready(); @@ -228,6 +238,68 @@ describe("Test ticket purchase verification", async () => { name: "TicketNotValidError", }); }); + + test("Sad path: ticket was refunded", async () => { + const conditionalError = new ConditionalCheckFailedException({ + message: "The conditional request failed", + $metadata: {}, + }); + (conditionalError as any).Item = { + ticket_id: { + S: "975b4470cf37d7cf20fd404a711513fd1d1e68259ded27f10727d1384961843d", + }, + used: { BOOL: false }, + refunded: { BOOL: true }, + }; + + ddbMock.on(UpdateItemCommand).rejects(conditionalError); + + const testJwt = createJwt(); + await app.ready(); + const response = await supertest(app.server) + .post("/api/v1/tickets/checkIn") + .set("authorization", `Bearer ${testJwt}`) + .send({ + type: "ticket", + ticketId: + "975b4470cf37d7cf20fd404a711513fd1d1e68259ded27f10727d1384961843d", + }); + const responseDataJson = response.body; + expect(response.statusCode).toEqual(400); + expect(responseDataJson).toEqual({ + error: true, + id: 109, + message: "Ticket was already refunded.", + name: "TicketNotValidError", + }); + }); + + test("Sad path: ticket does not exist", async () => { + const conditionalError = new ConditionalCheckFailedException({ + message: "The conditional request failed", + $metadata: {}, + }); + + ddbMock.on(UpdateItemCommand).rejects(conditionalError); + + const testJwt = createJwt(); + await app.ready(); + const response = await supertest(app.server) + .post("/api/v1/tickets/checkIn") + .set("authorization", `Bearer ${testJwt}`) + .send({ + type: "ticket", + ticketId: "nonexistentticketid123456789", + }); + const responseDataJson = response.body; + expect(response.statusCode).toEqual(404); + expect(responseDataJson).toEqual({ + error: true, + id: 108, + message: "Ticket does not exist.", + name: "TicketNotFoundError", + }); + }); }); describe("Test merch purchase verification", async () => { @@ -287,10 +359,18 @@ describe("Test merch purchase verification", async () => { }); }); test("Sad path: fulfilling a refunded merch item fails", async () => { - ddbMock - .on(UpdateItemCommand) - .resolvesOnce({ Attributes: refundedMerchItem }) - .resolvesOnce({}); + const conditionalError = new ConditionalCheckFailedException({ + message: "The conditional request failed", + $metadata: {}, + }); + (conditionalError as any).Item = { + stripe_pi: { S: "pi_6T9QvUwR2IOj4CyF35DsXK7P" }, + email: { S: "testing2@illinois.edu" }, + fulfilled: { BOOL: false }, + refunded: { BOOL: true }, + }; + + ddbMock.on(UpdateItemCommand).rejects(conditionalError); const testJwt = createJwt(); await app.ready(); @@ -312,10 +392,18 @@ describe("Test merch purchase verification", async () => { }); }); test("Sad path: fulfilling an already-fulfilled merch item fails", async () => { - ddbMock - .on(UpdateItemCommand) - .resolvesOnce({ Attributes: fulfilledMerchItem1 }) - .resolvesOnce({}); + const conditionalError = new ConditionalCheckFailedException({ + message: "The conditional request failed", + $metadata: {}, + }); + (conditionalError as any).Item = { + stripe_pi: { S: "pi_3Q5GewDiGOXU9RuS16txRR5D" }, + email: { S: "testing0@illinois.edu" }, + fulfilled: { BOOL: true }, + refunded: { BOOL: false }, + }; + + ddbMock.on(UpdateItemCommand).rejects(conditionalError); const testJwt = createJwt(); await app.ready(); @@ -336,6 +424,68 @@ describe("Test merch purchase verification", async () => { name: "TicketNotValidError", }); }); + + test("Sad path: merch item does not exist", async () => { + const conditionalError = new ConditionalCheckFailedException({ + message: "The conditional request failed", + $metadata: {}, + }); + + ddbMock.on(UpdateItemCommand).rejects(conditionalError); + + const testJwt = createJwt(); + await app.ready(); + const response = await supertest(app.server) + .post("/api/v1/tickets/checkIn") + .set("authorization", `Bearer ${testJwt}`) + .send({ + type: "merch", + email: "nonexistent@illinois.edu", + stripePi: "pi_nonexistent123456", + }); + const responseDataJson = response.body; + expect(response.statusCode).toEqual(404); + expect(responseDataJson).toEqual({ + error: true, + id: 108, + message: "Ticket does not exist.", + name: "TicketNotFoundError", + }); + }); + + test("Sad path: wrong email for merch item", async () => { + const conditionalError = new ConditionalCheckFailedException({ + message: "The conditional request failed", + $metadata: {}, + }); + (conditionalError as any).Item = { + stripe_pi: { S: "pi_8J4NrYdA3S7cW8Ty92FnGJ6L" }, + email: { S: "testing1@illinois.edu" }, + fulfilled: { BOOL: false }, + refunded: { BOOL: false }, + }; + + ddbMock.on(UpdateItemCommand).rejects(conditionalError); + + const testJwt = createJwt(); + await app.ready(); + const response = await supertest(app.server) + .post("/api/v1/tickets/checkIn") + .set("authorization", `Bearer ${testJwt}`) + .send({ + type: "merch", + email: "wrong@illinois.edu", + stripePi: "pi_8J4NrYdA3S7cW8Ty92FnGJ6L", + }); + const responseDataJson = response.body; + expect(response.statusCode).toEqual(404); + expect(responseDataJson).toEqual({ + error: true, + id: 108, + message: "Ticket does not exist.", + name: "TicketNotFoundError", + }); + }); }); describe("Test getting all issued tickets", async () => {