diff --git a/src/api/routes/stripe.ts b/src/api/routes/stripe.ts index 0a71b88..4f26a5b 100644 --- a/src/api/routes/stripe.ts +++ b/src/api/routes/stripe.ts @@ -23,7 +23,9 @@ import { DatabaseFetchError, DatabaseInsertError, InternalServerError, + NotFoundError, UnauthenticatedError, + UnauthorizedError, ValidationError, } from "common/errors/index.js"; import { Modules } from "common/modules.js"; @@ -39,6 +41,7 @@ import stripe, { Stripe } from "stripe"; import rawbody from "fastify-raw-body"; import { AvailableSQSFunctions, SQSPayload } from "common/types/sqsMessage.js"; import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs"; +import { z } from "zod"; const stripeRoutes: FastifyPluginAsync = async (fastify, _options) => { await fastify.register(rawbody, { @@ -177,6 +180,112 @@ const stripeRoutes: FastifyPluginAsync = async (fastify, _options) => { reply.status(201).send({ id: linkId, link: url }); }, ); + fastify.withTypeProvider().delete( + "/paymentLinks/:linkId", + { + schema: withRoles( + [AppRoles.STRIPE_LINK_CREATOR], + withTags(["Stripe"], { + summary: "Deactivate a Stripe payment link.", + params: z.object({ + linkId: z.string().min(1).openapi({ + description: "Payment Link ID", + example: "plink_abc123", + }), + }), + }), + ), + onRequest: fastify.authorizeFromSchema, + }, + async (request, reply) => { + if (!request.username) { + throw new UnauthenticatedError({ message: "No username found" }); + } + const { linkId } = request.params; + const response = await fastify.dynamoClient.send( + new QueryCommand({ + TableName: genericConfig.StripeLinksDynamoTableName, + IndexName: "LinkIdIndex", + KeyConditionExpression: "linkId = :linkId", + ExpressionAttributeValues: { + ":linkId": { S: linkId }, + }, + }), + ); + if (!response) { + throw new DatabaseFetchError({ + message: "Could not check for payment link in table.", + }); + } + if (!response.Items || response.Items?.length !== 1) { + throw new NotFoundError({ endpointName: request.url }); + } + const unmarshalledEntry = unmarshall(response.Items[0]) as { + userId: string; + invoiceId: string; + amount?: number; + priceId?: string; + productId?: string; + }; + if ( + unmarshalledEntry.userId !== request.username && + !request.userRoles?.has(AppRoles.BYPASS_OBJECT_LEVEL_AUTH) + ) { + throw new UnauthorizedError({ + message: "Not authorized to deactivate this payment link.", + }); + } + const logStatement = buildAuditLogTransactPut({ + entry: { + module: Modules.STRIPE, + actor: request.username, + target: `Link ${linkId} | Invoice ${unmarshalledEntry.invoiceId}`, + message: "Deactivated Stripe payment link", + }, + }); + const dynamoCommand = new TransactWriteItemsCommand({ + TransactItems: [ + logStatement, + { + Update: { + TableName: genericConfig.StripeLinksDynamoTableName, + Key: { + userId: { S: unmarshalledEntry.userId }, + linkId: { S: linkId }, + }, + UpdateExpression: "SET active = :new_val", + ConditionExpression: "active = :old_val", + ExpressionAttributeValues: { + ":new_val": { BOOL: false }, + ":old_val": { BOOL: true }, + }, + }, + }, + ], + }); + const secretApiConfig = + (await getSecretValue( + fastify.secretsManagerClient, + genericConfig.ConfigSecretName, + )) || {}; + if (unmarshalledEntry.productId) { + request.log.debug( + `Deactivating Stripe product ${unmarshalledEntry.productId}`, + ); + await deactivateStripeProduct({ + stripeApiKey: secretApiConfig.stripe_secret_key as string, + productId: unmarshalledEntry.productId, + }); + } + request.log.debug(`Deactivating Stripe link ${linkId}`); + await deactivateStripeLink({ + stripeApiKey: secretApiConfig.stripe_secret_key as string, + linkId, + }); + await fastify.dynamoClient.send(dynamoCommand); + return reply.status(201).send(); + }, + ); fastify.post( "/webhook", { diff --git a/tests/unit/stripe.test.ts b/tests/unit/stripe.test.ts index e1a2dcf..993adac 100644 --- a/tests/unit/stripe.test.ts +++ b/tests/unit/stripe.test.ts @@ -1,4 +1,12 @@ -import { afterAll, expect, test, beforeEach, vi, describe } from "vitest"; +import { + afterAll, + expect, + test, + beforeEach, + vi, + describe, + afterEach, +} from "vitest"; import init from "../../src/api/index.js"; import { mockClient } from "aws-sdk-client-mock"; import { secretJson } from "./secret.testdata.js"; @@ -13,7 +21,6 @@ import supertest from "supertest"; import { createJwt } from "./auth.test.js"; import { v4 as uuidv4 } from "uuid"; import { marshall } from "@aws-sdk/util-dynamodb"; -import { genericConfig } from "../../src/common/config.js"; const ddbMock = mockClient(DynamoDBClient); const linkId = uuidv4(); @@ -31,12 +38,14 @@ vi.mock("stripe", () => { default: vi.fn(() => ({ products: { create: vi.fn().mockResolvedValue(productMock), + update: vi.fn().mockResolvedValue({}), }, prices: { create: vi.fn().mockResolvedValue(priceMock), }, paymentLinks: { create: vi.fn().mockResolvedValue(paymentLinkMock), + update: vi.fn().mockResolvedValue({}), }, })), }; @@ -207,7 +216,7 @@ describe("Test Stripe link creation", async () => { ddbMock .on(ScanCommand) .rejects(new Error("Should not be called when OLA is enforced!")); - ddbMock.on(QueryCommand).resolves({ + ddbMock.on(QueryCommand).resolvesOnce({ Count: 1, Items: [ marshall({ @@ -242,9 +251,62 @@ describe("Test Stripe link creation", async () => { }, ]); }); + test("DELETE happy path", async () => { + ddbMock.on(QueryCommand).resolvesOnce({ + Items: [ + marshall({ + userId: "infra@acm.illinois.edu", + invoiceId: "UNITTEST1", + amount: 10000, + priceId: "price_abc123", + productId: "prod_abc123", + }), + ], + }); + ddbMock.on(TransactWriteItemsCommand).resolvesOnce({}); + const testJwt = createJwt(); + await app.ready(); + + const response = await supertest(app.server) + .delete("/api/v1/stripe/paymentLinks/plink_abc123") + .set("authorization", `Bearer ${testJwt}`) + .send(); + expect(response.statusCode).toBe(201); + expect(ddbMock.calls().length).toEqual(2); + }); + test("DELETE fails on not user-owned links", async () => { + await app.ready(); + ddbMock.on(QueryCommand).resolvesOnce({ + Items: [ + marshall({ + userId: "defsuperdupernotme@acm.illinois.edu", + invoiceId: "UNITTEST1", + amount: 10000, + priceId: "price_abc123", + productId: "prod_abc123", + }), + ], + }); + ddbMock.on(TransactWriteItemsCommand).rejects(); + const testJwt = createJwt( + undefined, + ["999"], + "infra-unit-test-stripeonly@acm.illinois.edu", + ); + + const response = await supertest(app.server) + .delete("/api/v1/stripe/paymentLinks/plink_abc123") + .set("authorization", `Bearer ${testJwt}`) + .send(); + expect(response.statusCode).toBe(401); + expect(ddbMock.calls().length).toEqual(1); + }); afterAll(async () => { await app.close(); }); + afterEach(() => { + ddbMock.reset(); + }); beforeEach(() => { (app as any).nodeCache.flushAll(); vi.clearAllMocks(); diff --git a/tests/unit/vitest.config.ts b/tests/unit/vitest.config.ts index e67573c..30045a1 100644 --- a/tests/unit/vitest.config.ts +++ b/tests/unit/vitest.config.ts @@ -13,8 +13,8 @@ export default defineConfig({ include: ["src/api/**/*.ts", "src/common/**/*.ts"], exclude: ["src/api/lambda.ts", "src/api/sqs/handlers/templates/*.ts"], thresholds: { - statements: 54, - functions: 65, + statements: 55, + functions: 66, lines: 54, }, }, diff --git a/tests/unit/vitest.setup.ts b/tests/unit/vitest.setup.ts index 663e660..6de7483 100644 --- a/tests/unit/vitest.setup.ts +++ b/tests/unit/vitest.setup.ts @@ -51,6 +51,7 @@ vi.mock( "scanner-only": [AppRoles.TICKETS_SCANNER], LINKS_ADMIN: [AppRoles.LINKS_ADMIN], LINKS_MANAGER: [AppRoles.LINKS_MANAGER], + "999": [AppRoles.STRIPE_LINK_CREATOR], }; return mockGroupRoles[groupId as any] || [];