From b4f3064ec772cd8a500ec6c749f07c65db60195d Mon Sep 17 00:00:00 2001 From: Megh Patel Date: Wed, 13 May 2026 19:12:59 -0500 Subject: [PATCH] added new sqs flow for callback urls on creating stripe url links --- src/api/functions/subscriberCallback.ts | 176 ++++++++++++++++++ src/api/routes/stripe.ts | 139 +++++++++++++- src/api/sqs/handlers/index.ts | 1 + .../stripeLinkSubscriberCallbackHandler.ts | 73 ++++++++ src/api/sqs/index.ts | 3 + src/common/types/sqsMessage.ts | 124 +++++++----- src/common/types/stripe.ts | 69 +++++-- tests/live/stripe.test.ts | 51 +++++ tests/unit/common/sqsMessage.test.ts | 44 +++++ ...tripeLinkSubscriberCallbackHandler.test.ts | 159 ++++++++++++++++ tests/unit/sqs/subscriberCallback.test.ts | 140 ++++++++++++++ 11 files changed, 913 insertions(+), 66 deletions(-) create mode 100644 src/api/functions/subscriberCallback.ts create mode 100644 src/api/sqs/handlers/stripeLinkSubscriberCallbackHandler.ts create mode 100644 tests/unit/sqs/handlers/stripeLinkSubscriberCallbackHandler.test.ts create mode 100644 tests/unit/sqs/subscriberCallback.test.ts diff --git a/src/api/functions/subscriberCallback.ts b/src/api/functions/subscriberCallback.ts new file mode 100644 index 000000000..25fb5575c --- /dev/null +++ b/src/api/functions/subscriberCallback.ts @@ -0,0 +1,176 @@ +import { createHmac } from "crypto"; +import { lookup } from "dns/promises"; +import { isIP } from "net"; +import type { ValidLoggers } from "api/types.js"; + +const PRIVATE_IPV4_RANGES: { cidr: string; mask: number }[] = [ + { cidr: "10.0.0.0", mask: 8 }, + { cidr: "172.16.0.0", mask: 12 }, + { cidr: "192.168.0.0", mask: 16 }, + { cidr: "127.0.0.0", mask: 8 }, + { cidr: "169.254.0.0", mask: 16 }, + { cidr: "0.0.0.0", mask: 8 }, + { cidr: "100.64.0.0", mask: 10 }, +]; + +const ipv4ToInt = (ip: string): number => + ip.split(".").reduce((acc, part) => (acc << 8) + Number(part), 0) >>> 0; + +const isPrivateIPv4 = (ip: string): boolean => { + const ipInt = ipv4ToInt(ip); + return PRIVATE_IPV4_RANGES.some(({ cidr, mask }) => { + const cidrInt = ipv4ToInt(cidr); + const maskBits = mask === 0 ? 0 : (~0 << (32 - mask)) >>> 0; + return (ipInt & maskBits) === (cidrInt & maskBits); + }); +}; + +const isPrivateIPv6 = (ip: string): boolean => { + const normalized = ip.toLowerCase(); + if (normalized === "::1") { + return true; + } + if (normalized.startsWith("fc") || normalized.startsWith("fd")) { + return true; // fc00::/7 + } + if ( + normalized.startsWith("fe8") || + normalized.startsWith("fe9") || + normalized.startsWith("fea") || + normalized.startsWith("feb") + ) { + return true; // fe80::/10 + } + return false; +}; + +export class SubscriberCallbackBlockedError extends Error { + constructor(message: string) { + super(message); + this.name = "SubscriberCallbackBlockedError"; + } +} + +export const assertCallbackUrlIsExternal = async ( + url: string, +): Promise => { + const parsed = new URL(url); + if (parsed.protocol !== "https:") { + throw new SubscriberCallbackBlockedError("callbackUrl must use https://"); + } + const host = parsed.hostname.replace(/^\[(.*)\]$/, "$1").toLowerCase(); + if (host === "localhost") { + throw new SubscriberCallbackBlockedError( + "callbackUrl host is not reachable.", + ); + } + const ipVersion = isIP(host); + if (ipVersion === 4 && isPrivateIPv4(host)) { + throw new SubscriberCallbackBlockedError( + "callbackUrl resolves to a private IPv4 range.", + ); + } + if (ipVersion === 6 && isPrivateIPv6(host)) { + throw new SubscriberCallbackBlockedError( + "callbackUrl resolves to a private IPv6 range.", + ); + } + if (ipVersion !== 0) { + return; + } + const resolved = await lookup(host, { all: true }); + if (resolved.length === 0) { + throw new SubscriberCallbackBlockedError( + `callbackUrl host ${host} did not resolve.`, + ); + } + for (const entry of resolved) { + if (entry.family === 4 && isPrivateIPv4(entry.address)) { + throw new SubscriberCallbackBlockedError( + `callbackUrl host ${host} resolves to private IPv4 ${entry.address}.`, + ); + } + if (entry.family === 6 && isPrivateIPv6(entry.address)) { + throw new SubscriberCallbackBlockedError( + `callbackUrl host ${host} resolves to private IPv6 ${entry.address}.`, + ); + } + } +}; + +export const signCallbackBody = ({ + body, + signingSecret, + timestamp, +}: { + body: string; + signingSecret: string; + timestamp: number; +}): string => { + const hmac = createHmac("sha256", signingSecret); + hmac.update(`${timestamp}.${body}`); + return hmac.digest("hex"); +}; + +export type DeliverSubscriberCallbackParams = { + callbackUrl: string; + signingSecret: string; + body: object; + eventId: string; + logger: ValidLoggers; + timeoutMs?: number; +}; + +export const deliverSubscriberCallback = async ({ + callbackUrl, + signingSecret, + body, + eventId, + logger, + timeoutMs = 5000, +}: DeliverSubscriberCallbackParams): Promise => { + await assertCallbackUrlIsExternal(callbackUrl); + const serialized = JSON.stringify(body); + const timestamp = Math.floor(Date.now() / 1000); + const signature = signCallbackBody({ + body: serialized, + signingSecret, + timestamp, + }); + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + let response: Response; + try { + response = await fetch(callbackUrl, { + method: "POST", + body: serialized, + headers: { + "Content-Type": "application/json", + "X-ACM-Signature": `t=${timestamp},v1=${signature}`, + "X-ACM-Event-Id": eventId, + }, + signal: controller.signal, + }); + } finally { + clearTimeout(timer); + } + + if (!response.ok) { + const truncated = await response + .text() + .then((t) => t.slice(0, 256)) + .catch(() => ""); + logger.warn( + { status: response.status, callbackUrl, eventId, body: truncated }, + "Subscriber callback returned non-2xx; will retry.", + ); + throw new Error( + `Subscriber callback returned ${response.status} from ${callbackUrl}`, + ); + } + logger.info( + { status: response.status, callbackUrl, eventId }, + "Subscriber callback delivered.", + ); +}; diff --git a/src/api/routes/stripe.ts b/src/api/routes/stripe.ts index ef321869d..c163896ae 100644 --- a/src/api/routes/stripe.ts +++ b/src/api/routes/stripe.ts @@ -39,7 +39,11 @@ import { FastifyPluginAsync } from "fastify"; import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi"; import stripe, { Stripe } from "stripe"; import rawbody from "fastify-raw-body"; -import { AvailableSQSFunctions, SQSPayload } from "common/types/sqsMessage.js"; +import { + AvailableSQSFunctions, + SQSPayload, + StripeLinkCallbackEventType, +} from "common/types/sqsMessage.js"; import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs"; import * as z from "zod/v4"; import { getAllUserEmails } from "common/utils.js"; @@ -49,6 +53,7 @@ import { } from "common/constants.js"; import { assertAuthenticated } from "api/authenticated.js"; import { maxLength } from "common/types/generic.js"; +import { randomBytes } from "crypto"; const stripeRoutes: FastifyPluginAsync = async (fastify, _options) => { await fastify.register(rawbody, { @@ -56,6 +61,70 @@ const stripeRoutes: FastifyPluginAsync = async (fastify, _options) => { global: false, runFirst: true, }); + const enqueueSubscriberCallback = async ({ + requestLog, + requestId, + eventId, + eventType, + linkId, + callbackUrl, + invoiceId, + amount, + currency, + paidInFull, + paymentMethod, + payerName, + payerEmail, + }: { + requestLog: { info: (msg: string) => void }; + requestId: string; + eventId: string; + eventType: StripeLinkCallbackEventType; + linkId: string; + callbackUrl: string | undefined; + invoiceId: string; + amount: number; + currency: string; + paidInFull: boolean; + paymentMethod: string | null; + payerName: string | null; + payerEmail: string | null; + }) => { + if (!callbackUrl) { + return; + } + requestLog.info( + `Enqueueing ${eventType} subscriber callback for link ${linkId}`, + ); + const callbackPayload: SQSPayload = + { + function: AvailableSQSFunctions.StripeLinkSubscriberCallback, + metadata: { initiator: eventId, reqId: requestId }, + payload: { + linkId, + eventType, + eventId, + invoiceId, + amount, + currency, + paidInFull, + paymentMethod, + payerName, + payerEmail, + occurredAt: new Date().toISOString(), + }, + }; + if (!fastify.sqsClient) { + fastify.sqsClient = new SQSClient({ region: genericConfig.AwsRegion }); + } + await fastify.sqsClient.send( + new SendMessageCommand({ + QueueUrl: fastify.environmentConfig.SqsQueueUrl, + MessageBody: JSON.stringify(callbackPayload), + MessageGroupId: "stripeLinkCallback", + }), + ); + }; fastify.withTypeProvider().get( "/paymentLinks", { @@ -117,6 +186,7 @@ const stripeRoutes: FastifyPluginAsync = async (fastify, _options) => { invoiceId: item.invoiceId, invoiceAmountUsd: item.amount, createdAt: item.createdAt || null, + callbackUrl: item.callbackUrl || undefined, }), ); reply.status(200).send(parsed); @@ -156,12 +226,18 @@ const stripeRoutes: FastifyPluginAsync = async (fastify, _options) => { const { url, linkId, priceId, productId } = await createStripeLink(payload); const invoiceId = request.body.invoiceId; + const callbackUrl = request.body.callbackUrl; + const signingSecret = callbackUrl + ? randomBytes(32).toString("hex") + : undefined; const logStatement = buildAuditLogTransactPut({ entry: { module: Modules.STRIPE, actor: request.username, target: `Link ${linkId} | Invoice ${invoiceId}`, - message: "Created Stripe payment link", + message: callbackUrl + ? "Created Stripe payment link with subscriber callback" + : "Created Stripe payment link", }, }); const dynamoCommand = new TransactWriteItemsCommand({ @@ -181,6 +257,8 @@ const stripeRoutes: FastifyPluginAsync = async (fastify, _options) => { amount: request.body.invoiceAmountUsd, active: true, createdAt: new Date().toISOString(), + callbackUrl, + signingSecret, }, { removeUndefinedValues: true }, ), @@ -206,7 +284,11 @@ const stripeRoutes: FastifyPluginAsync = async (fastify, _options) => { message: "Could not write Stripe link to database.", }); } - reply.status(201).send({ id: linkId, link: url }); + reply.status(201).send({ + id: linkId, + link: url, + ...(signingSecret ? { signingSecret } : {}), + }); }), ); fastify.withTypeProvider().delete( @@ -262,6 +344,8 @@ const stripeRoutes: FastifyPluginAsync = async (fastify, _options) => { amount?: number; priceId?: string; productId?: string; + callbackUrl?: string; + signingSecret?: string; }; if ( unmarshalledEntry.userId !== request.username && @@ -417,6 +501,8 @@ const stripeRoutes: FastifyPluginAsync = async (fastify, _options) => { amount?: number; priceId?: string; productId?: string; + callbackUrl?: string; + signingSecret?: string; }; if (!unmarshalledEntry.userId || !unmarshalledEntry.invoiceId) { return reply.status(200).send({ @@ -478,6 +564,21 @@ Please ask the payee to try again, perhaps with a different payment method, or c ); queueId = result.MessageId || ""; } + await enqueueSubscriberCallback({ + requestLog: request.log, + requestId: request.id, + eventId, + eventType: "payment.failed", + linkId: paymentLinkId, + callbackUrl: unmarshalledEntry.callbackUrl, + invoiceId: unmarshalledEntry.invoiceId, + amount: paymentAmount, + currency: paymentCurrency, + paidInFull, + paymentMethod: null, + payerName: name, + payerEmail: email, + }); } return reply.status(200).send({ @@ -570,6 +671,8 @@ Please ask the payee to try again, perhaps with a different payment method, or c amount?: number; priceId?: string; productId?: string; + callbackUrl?: string; + signingSecret?: string; }; if (!unmarshalledEntry.userId || !unmarshalledEntry.invoiceId) { return reply.status(200).send({ @@ -633,6 +736,21 @@ Please contact Officer Board with any questions. ); queueId = result.MessageId || ""; } + await enqueueSubscriberCallback({ + requestLog: request.log, + requestId: request.id, + eventId, + eventType: "payment.pending", + linkId: paymentLinkId, + callbackUrl: unmarshalledEntry.callbackUrl, + invoiceId: unmarshalledEntry.invoiceId, + amount: paymentAmount, + currency: paymentCurrency, + paidInFull, + paymentMethod: paymentMethodString ?? null, + payerName: name, + payerEmail: email, + }); } else { request.log.info( `Registered payment of ${withCurrency} by ${name} (${email}) for payment link ${paymentLinkId} invoice ID ${unmarshalledEntry.invoiceId}). Invoice was paid ${paidInFull ? "in full." : "partially."}`, @@ -682,6 +800,21 @@ Please contact Officer Board with any questions.`, ); queueId = result.MessageId || ""; } + await enqueueSubscriberCallback({ + requestLog: request.log, + requestId: request.id, + eventId, + eventType: "payment.succeeded", + linkId: paymentLinkId, + callbackUrl: unmarshalledEntry.callbackUrl, + invoiceId: unmarshalledEntry.invoiceId, + amount: paymentAmount, + currency: paymentCurrency, + paidInFull, + paymentMethod: paymentMethodString ?? null, + payerName: name, + payerEmail: email, + }); // If full payment is done, disable the link if (paidInFull) { request.log.debug("Paid in full, disabling link."); diff --git a/src/api/sqs/handlers/index.ts b/src/api/sqs/handlers/index.ts index e83435877..87bf707bc 100644 --- a/src/api/sqs/handlers/index.ts +++ b/src/api/sqs/handlers/index.ts @@ -7,3 +7,4 @@ export { emailNotificationsHandler } from "./emailNotifications.js"; export { createOrgGithubTeamHandler } from "./createOrgGithubTeam.js"; export { syncExecCouncilHandler } from "./syncExecCouncil.js"; export { processStorePurchaseHandler } from "./handleStorePurchase.js"; +export { stripeLinkSubscriberCallbackHandler } from "./stripeLinkSubscriberCallbackHandler.js"; diff --git a/src/api/sqs/handlers/stripeLinkSubscriberCallbackHandler.ts b/src/api/sqs/handlers/stripeLinkSubscriberCallbackHandler.ts new file mode 100644 index 000000000..b4385d461 --- /dev/null +++ b/src/api/sqs/handlers/stripeLinkSubscriberCallbackHandler.ts @@ -0,0 +1,73 @@ +import { AvailableSQSFunctions } from "common/types/sqsMessage.js"; +import { SQSHandlerFunction } from "../index.js"; +import { DynamoDBClient, QueryCommand } from "@aws-sdk/client-dynamodb"; +import { unmarshall } from "@aws-sdk/util-dynamodb"; +import { genericConfig } from "common/config.js"; +import { + deliverSubscriberCallback, + SubscriberCallbackBlockedError, +} from "api/functions/subscriberCallback.js"; + +export const stripeLinkSubscriberCallbackHandler: SQSHandlerFunction< + AvailableSQSFunctions.StripeLinkSubscriberCallback +> = async (payload, _metadata, logger) => { + const dynamoClient = new DynamoDBClient({ region: genericConfig.AwsRegion }); + const response = await dynamoClient.send( + new QueryCommand({ + TableName: genericConfig.StripeLinksDynamoTableName, + IndexName: "LinkIdIndex", + KeyConditionExpression: "linkId = :linkId", + ExpressionAttributeValues: { + ":linkId": { S: payload.linkId }, + }, + }), + ); + if (!response.Items || response.Items.length !== 1) { + logger.warn( + { linkId: payload.linkId }, + "Stripe link not found for subscriber callback; dropping message.", + ); + return; + } + const entry = unmarshall(response.Items[0]) as { + callbackUrl?: string; + signingSecret?: string; + }; + if (!entry.callbackUrl || !entry.signingSecret) { + logger.info( + { linkId: payload.linkId }, + "Stripe link has no callbackUrl/signingSecret; dropping message.", + ); + return; + } + try { + await deliverSubscriberCallback({ + callbackUrl: entry.callbackUrl, + signingSecret: entry.signingSecret, + eventId: payload.eventId, + body: { + type: payload.eventType, + eventId: payload.eventId, + linkId: payload.linkId, + invoiceId: payload.invoiceId, + amount: payload.amount, + currency: payload.currency, + paidInFull: payload.paidInFull, + paymentMethod: payload.paymentMethod ?? null, + payerName: payload.payerName ?? null, + payerEmail: payload.payerEmail ?? null, + occurredAt: payload.occurredAt, + }, + logger, + }); + } catch (error) { + if (error instanceof SubscriberCallbackBlockedError) { + logger.error( + { error: error.message, linkId: payload.linkId }, + "Subscriber callback blocked; dropping (not retrying).", + ); + return; + } + throw error; + } +}; diff --git a/src/api/sqs/index.ts b/src/api/sqs/index.ts index 320530358..c1040066c 100644 --- a/src/api/sqs/index.ts +++ b/src/api/sqs/index.ts @@ -22,6 +22,7 @@ import { syncExecCouncilHandler, processStorePurchaseHandler, sendSaleFailedHandler, + stripeLinkSubscriberCallbackHandler, } from "./handlers/index.js"; import { ValidationError } from "../../common/errors/index.js"; import { RunEnvironment } from "../../common/roles.js"; @@ -47,6 +48,8 @@ const handlers: SQSFunctionPayloadTypes = { [AvailableSQSFunctions.SyncExecCouncil]: syncExecCouncilHandler, [AvailableSQSFunctions.HandleStorePurchase]: processStorePurchaseHandler, [AvailableSQSFunctions.SendSaleFailedEmail]: sendSaleFailedHandler, + [AvailableSQSFunctions.StripeLinkSubscriberCallback]: + stripeLinkSubscriberCallbackHandler, }; export const runEnvironment = process.env.RunEnvironment as RunEnvironment; export const currentEnvironmentConfig = environmentConfig[runEnvironment]; diff --git a/src/common/types/sqsMessage.ts b/src/common/types/sqsMessage.ts index b980ba080..e550f8299 100644 --- a/src/common/types/sqsMessage.ts +++ b/src/common/types/sqsMessage.ts @@ -11,43 +11,56 @@ export enum AvailableSQSFunctions { CreateOrgGithubTeam = "createOrgGithubTeam", SyncExecCouncil = "syncExecCouncil", HandleStorePurchase = "handleStorePurchase", + StripeLinkSubscriberCallback = "stripeLinkSubscriberCallback", } +export const stripeLinkCallbackEventTypes = [ + "payment.succeeded", + "payment.pending", + "payment.failed", +] as const; +export type StripeLinkCallbackEventType = + (typeof stripeLinkCallbackEventTypes)[number]; + const sqsMessageMetadataSchema = z.object({ reqId: z.string().min(1), - initiator: z.string().min(1) + initiator: z.string().min(1), }); export type SQSMessageMetadata = z.infer; const baseSchema = z.object({ - metadata: sqsMessageMetadataSchema + metadata: sqsMessageMetadataSchema, }); const createSQSSchema = < T extends AvailableSQSFunctions, - P extends z.ZodType>( - - func: T, - payloadSchema: P) => - + P extends z.ZodTypeAny, +>( + func: T, + payloadSchema: P, +) => baseSchema.extend({ function: z.literal(func), - payload: payloadSchema + payload: payloadSchema, }); export const sqsPayloadSchemas = { [AvailableSQSFunctions.Ping]: createSQSSchema( AvailableSQSFunctions.Ping, - z.object({}) + z.object({}), ), [AvailableSQSFunctions.EmailMembershipPass]: createSQSSchema( AvailableSQSFunctions.EmailMembershipPass, - z.object({ email: z.email(), firstName: z.optional(z.string().min(1)) }) + z.object({ email: z.email(), firstName: z.optional(z.string().min(1)) }), ), [AvailableSQSFunctions.ProvisionNewMember]: createSQSSchema( AvailableSQSFunctions.ProvisionNewMember, - z.object({ email: z.email(), firstName: z.string().min(1), lastName: z.string().min(1) }) + z.object({ + email: z.email(), + firstName: z.string().min(1), + lastName: z.string().min(1), + }), ), [AvailableSQSFunctions.SendSaleEmail]: createSQSSchema( AvailableSQSFunctions.SendSaleEmail, @@ -55,71 +68,95 @@ export const sqsPayloadSchemas = { email: z.email(), qrCodeContent: z.string().min(1), customText: z.string().optional(), - itemsPurchased: z.array(z.object({ - itemName: z.string().min(1), - variantName: z.string().min(1).optional(), - quantity: z.number().nonnegative(), - })).min(1), - isVerifiedIdentity: z.boolean().default(false) - }) + itemsPurchased: z + .array( + z.object({ + itemName: z.string().min(1), + variantName: z.string().min(1).optional(), + quantity: z.number().nonnegative(), + }), + ) + .min(1), + isVerifiedIdentity: z.boolean().default(false), + }), ), [AvailableSQSFunctions.EmailNotifications]: createSQSSchema( - AvailableSQSFunctions.EmailNotifications, z.object({ + AvailableSQSFunctions.EmailNotifications, + z.object({ to: z.array(z.email()).min(1), cc: z.optional(z.array(z.email()).min(1)), bcc: z.optional(z.array(z.string().email()).min(1)), subject: z.string().min(1), content: z.string().min(1), - callToActionButton: z.object({ - name: z.string().min(1), - url: z.string().min(1).url() - }).optional() - }) + callToActionButton: z + .object({ + name: z.string().min(1), + url: z.string().min(1).url(), + }) + .optional(), + }), ), [AvailableSQSFunctions.CreateOrgGithubTeam]: createSQSSchema( - AvailableSQSFunctions.CreateOrgGithubTeam, z.object({ + AvailableSQSFunctions.CreateOrgGithubTeam, + z.object({ orgId: OrgUniqueId, githubTeamName: z.string().min(1), - githubTeamDescription: z.string().min(1) - }) + githubTeamDescription: z.string().min(1), + }), ), [AvailableSQSFunctions.SyncExecCouncil]: createSQSSchema( - AvailableSQSFunctions.SyncExecCouncil, z.object({}) + AvailableSQSFunctions.SyncExecCouncil, + z.object({}), ), [AvailableSQSFunctions.HandleStorePurchase]: createSQSSchema( - AvailableSQSFunctions.HandleStorePurchase, z.object({ + AvailableSQSFunctions.HandleStorePurchase, + z.object({ orderId: z.string().min(1), userId: z.email(), paymentIdentifier: z.string().min(1), paymentIntentId: z.string().min(1).optional(), - isVerifiedIdentity: z.boolean() - }) + isVerifiedIdentity: z.boolean(), + }), ), [AvailableSQSFunctions.SendSaleFailedEmail]: createSQSSchema( - AvailableSQSFunctions.SendSaleFailedEmail, z.object({ + AvailableSQSFunctions.SendSaleFailedEmail, + z.object({ userId: z.email(), - failureReason: z.string().min(1) - }) - ) + failureReason: z.string().min(1), + }), + ), + [AvailableSQSFunctions.StripeLinkSubscriberCallback]: createSQSSchema( + AvailableSQSFunctions.StripeLinkSubscriberCallback, + z.object({ + linkId: z.string().min(1), + eventType: z.enum(stripeLinkCallbackEventTypes), + eventId: z.string().min(1), + invoiceId: z.string().min(1), + amount: z.number().int().nonnegative(), + currency: z.string().min(1), + paidInFull: z.boolean(), + paymentMethod: z.string().nullable().optional(), + payerName: z.string().nullable().optional(), + payerEmail: z.string().nullable().optional(), + occurredAt: z.iso.datetime(), + }), + ), } as const; // Add this type helper -type AllSchemas = { - [K in AvailableSQSFunctions]: (typeof sqsPayloadSchemas)[K]; -}; export const sqsPayloadSchema = z.discriminatedUnion( "function", Object.values(sqsPayloadSchemas) as [ (typeof sqsPayloadSchemas)[AvailableSQSFunctions], (typeof sqsPayloadSchemas)[AvailableSQSFunctions], - ...((typeof sqsPayloadSchemas)[AvailableSQSFunctions])[] - ] + ...(typeof sqsPayloadSchemas)[AvailableSQSFunctions][], + ], ); export type SQSPayload = z.infer< - (typeof sqsPayloadSchemas)[T]>; - + (typeof sqsPayloadSchemas)[T] +>; export type AnySQSPayload = z.infer; @@ -127,7 +164,6 @@ export function parseSQSPayload(json: unknown): AnySQSPayload | z.ZodError { const parsed = sqsPayloadSchema.safeParse(json); if (parsed.success) { return parsed.data; - } else { - return parsed.error; } + return parsed.error; } diff --git a/src/common/types/stripe.ts b/src/common/types/stripe.ts index ce6cf100b..f69ba7c85 100644 --- a/src/common/types/stripe.ts +++ b/src/common/types/stripe.ts @@ -1,50 +1,81 @@ import * as z from "zod/v4"; - const id = z.string().min(1).meta({ description: "The Payment Link's ID in the Stripe API", -}) +}); const link = z.url().meta({ description: "The Payment Link URL", -}) -const invoiceId = z.string().min(1).meta({ description: "Invoice identifier. Should be prefixed with an organization identifier to allow for easy processing." }); -const invoiceAmountUsd = z.number().min(50).meta({ description: "Billed amount, in cents." }); +}); +const invoiceId = z.string().min(1).meta({ + description: + "Invoice identifier. Should be prefixed with an organization identifier to allow for easy processing.", +}); +const invoiceAmountUsd = z + .number() + .min(50) + .meta({ description: "Billed amount, in cents." }); +const callbackUrl = z + .url() + .refine((u) => u.startsWith("https://"), { + message: "callbackUrl must use https://", + }) + .meta({ + description: + "HTTPS URL that will receive signed POST callbacks when payment events occur on this link.", + }); +const signingSecret = z.string().min(1).meta({ + description: + "Per-link HMAC-SHA256 signing secret. Returned once at creation; store it to verify incoming callbacks.", +}); export const invoiceLinkPostResponseSchema = z.object({ id, - link -}) + link, + signingSecret: z.optional(signingSecret), +}); export const invoiceLinkPostRequestSchema = z.object({ invoiceId, invoiceAmountUsd, - contactName: z.string().min(1).meta({ description: "Name of whomever the payment link is intended for." }), - contactEmail: z.email().meta({ description: "Email of whomever the payment link is intended for." }), - achPaymentsEnabled: z.optional(z.boolean()).default(false).meta({ description: "True if delayed settlement ACH push payments are enabled for this invoice." }), + contactName: z.string().min(1).meta({ + description: "Name of whomever the payment link is intended for.", + }), + contactEmail: z.email().meta({ + description: "Email of whomever the payment link is intended for.", + }), + achPaymentsEnabled: z.optional(z.boolean()).default(false).meta({ + description: + "True if delayed settlement ACH push payments are enabled for this invoice.", + }), + callbackUrl: z.optional(callbackUrl), }); export type PostInvoiceLinkRequest = z.infer< - typeof invoiceLinkPostRequestSchema>; - + typeof invoiceLinkPostRequestSchema +>; export type PostInvoiceLinkResponse = z.infer< - typeof invoiceLinkPostResponseSchema>; - + typeof invoiceLinkPostResponseSchema +>; export const invoiceLinkGetResponseSchema = z.array( invoiceLinkPostRequestSchema.extend({ id, link, userId: z.email().meta({ - description: 'The user ID of the user that created the payment link' + description: "The user ID of the user that created the payment link", }), active: z.boolean().meta({ - description: "True if the payment link is active and able to accept payments, false otherwise." + description: + "True if the payment link is active and able to accept payments, false otherwise.", }), invoiceId, invoiceAmountUsd, - createdAt: z.union([z.iso.datetime(), z.null()]).meta({ description: "When the payment link was created." }) - }) + createdAt: z + .union([z.iso.datetime(), z.null()]) + .meta({ description: "When the payment link was created." }), + }), ); export type GetInvoiceLinksResponse = z.infer< - typeof invoiceLinkGetResponseSchema>; + typeof invoiceLinkGetResponseSchema +>; diff --git a/tests/live/stripe.test.ts b/tests/live/stripe.test.ts index dde4522f9..55c32ce0c 100644 --- a/tests/live/stripe.test.ts +++ b/tests/live/stripe.test.ts @@ -49,6 +49,7 @@ describe("Stripe live API authentication", async () => { describe("Stripe link lifecycle test", { sequential: true }, async () => { const invoiceId = `LiveTest-${randomUUID().split("-")[0]}`; + const callbackUrl = "https://example.com/acm-core-live-test-callback"; let paymentLinkUrl: string | undefined; let paymentLinkId: string | undefined; test("Test that creating a link succeeds", { timeout: 10000 }, async () => { @@ -64,15 +65,65 @@ describe("Stripe link lifecycle test", { sequential: true }, async () => { contactName: "ACM Infra", contactEmail: "core-e2e-testing@acm.illinois.edu", achPaymentsEnabled: false, + callbackUrl, }), }); const body = await response.json(); expect(response.status).toBe(201); expect(body.link).toBeDefined(); expect(body.id).toBeDefined(); + expect(body.signingSecret).toMatch(/^[a-f0-9]{64}$/); paymentLinkUrl = body.link; paymentLinkId = body.id; }); + test( + "Test that http callback URLs are rejected", + { timeout: 10000 }, + async () => { + const response = await fetch( + `${baseEndpoint}/api/v1/stripe/paymentLinks`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + invoiceId: `LiveTest-${randomUUID().split("-")[0]}`, + invoiceAmountUsd: 1000, + contactName: "ACM Infra", + contactEmail: "core-e2e-testing@acm.illinois.edu", + achPaymentsEnabled: false, + callbackUrl: "http://example.com/acm-core-live-test-callback", + }), + }, + ); + expect(response.status).toBe(400); + }, + ); + test( + "Test that callbackUrl is exposed but signingSecret is not listed", + { timeout: 10000 }, + async () => { + const response = await fetch( + `${baseEndpoint}/api/v1/stripe/paymentLinks`, + { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + const body = await response.json(); + const createdLink = body.find( + (link: { id?: string }) => link.id === paymentLinkId, + ); + expect(response.status).toBe(200); + expect(createdLink).toBeDefined(); + expect(createdLink.callbackUrl).toBe(callbackUrl); + expect(createdLink.signingSecret).toBeUndefined(); + }, + ); test( "Test that accessing a created link succeeds", { timeout: 10000 }, diff --git a/tests/unit/common/sqsMessage.test.ts b/tests/unit/common/sqsMessage.test.ts index 4a89a1fff..5f2734c02 100644 --- a/tests/unit/common/sqsMessage.test.ts +++ b/tests/unit/common/sqsMessage.test.ts @@ -26,4 +26,48 @@ describe("SQS Message Parsing Tests", () => { }; expect(parseSQSPayload(payload)).toBeInstanceOf(ZodError); }); + test("Stripe link subscriber callback message parses correctly", () => { + const payload = { + metadata: { + reqId: "req_123", + initiator: "evt_123", + }, + function: "stripeLinkSubscriberCallback", + payload: { + linkId: "plink_123", + eventType: "payment.succeeded", + eventId: "evt_123", + invoiceId: "INV-123", + amount: 12345, + currency: "usd", + paidInFull: true, + paymentMethod: null, + payerName: null, + payerEmail: "payer@example.com", + occurredAt: "2026-05-13T12:00:00.000Z", + }, + }; + const response = parseSQSPayload(payload); + expect(response).toStrictEqual(payload); + }); + test("Stripe link subscriber callback rejects unknown event types", () => { + const payload = { + metadata: { + reqId: "req_123", + initiator: "evt_123", + }, + function: "stripeLinkSubscriberCallback", + payload: { + linkId: "plink_123", + eventType: "invoice.paid", + eventId: "evt_123", + invoiceId: "INV-123", + amount: 12345, + currency: "usd", + paidInFull: true, + occurredAt: "2026-05-13T12:00:00.000Z", + }, + }; + expect(parseSQSPayload(payload)).toBeInstanceOf(ZodError); + }); }); diff --git a/tests/unit/sqs/handlers/stripeLinkSubscriberCallbackHandler.test.ts b/tests/unit/sqs/handlers/stripeLinkSubscriberCallbackHandler.test.ts new file mode 100644 index 000000000..9e295c4c0 --- /dev/null +++ b/tests/unit/sqs/handlers/stripeLinkSubscriberCallbackHandler.test.ts @@ -0,0 +1,159 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { mockClient } from "aws-sdk-client-mock"; +import { DynamoDBClient, QueryCommand } from "@aws-sdk/client-dynamodb"; +import { marshall } from "@aws-sdk/util-dynamodb"; +import { genericConfig } from "../../../../src/common/config.js"; + +vi.mock("../../../../src/api/functions/subscriberCallback.js", async () => { + const actual = await vi.importActual< + typeof import("../../../../src/api/functions/subscriberCallback.js") + >("../../../../src/api/functions/subscriberCallback.js"); + return { + ...actual, + deliverSubscriberCallback: vi.fn(), + }; +}); + +import { + deliverSubscriberCallback, + SubscriberCallbackBlockedError, +} from "../../../../src/api/functions/subscriberCallback.js"; +import { stripeLinkSubscriberCallbackHandler } from "../../../../src/api/sqs/handlers/stripeLinkSubscriberCallbackHandler.js"; + +const ddbMock = mockClient(DynamoDBClient); +const deliverSubscriberCallbackMock = vi.mocked(deliverSubscriberCallback); + +describe("stripeLinkSubscriberCallbackHandler", () => { + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + + const metadata = { reqId: "req_123", initiator: "evt_123" }; + const payload = { + linkId: "plink_123", + eventType: "payment.succeeded" as const, + eventId: "evt_123", + invoiceId: "INV-123", + amount: 12345, + currency: "usd", + paidInFull: true, + paymentMethod: "Credit/Debit Card (Visa ending in 4242)", + payerName: undefined, + payerEmail: "payer@example.com", + occurredAt: "2026-05-13T12:00:00.000Z", + }; + + beforeEach(() => { + ddbMock.reset(); + vi.clearAllMocks(); + }); + + test("looks up the link and delivers the subscriber callback", async () => { + ddbMock.on(QueryCommand).resolvesOnce({ + Items: [ + marshall({ + callbackUrl: "https://callbacks.example.com/stripe", + signingSecret: "secret_123", + }), + ], + }); + + await stripeLinkSubscriberCallbackHandler(payload, metadata, logger as any); + + expect(ddbMock.commandCalls(QueryCommand)).toHaveLength(1); + expect(ddbMock.commandCalls(QueryCommand)[0].args[0].input).toStrictEqual({ + TableName: genericConfig.StripeLinksDynamoTableName, + IndexName: "LinkIdIndex", + KeyConditionExpression: "linkId = :linkId", + ExpressionAttributeValues: { + ":linkId": { S: "plink_123" }, + }, + }); + expect(deliverSubscriberCallbackMock).toHaveBeenCalledExactlyOnceWith({ + callbackUrl: "https://callbacks.example.com/stripe", + signingSecret: "secret_123", + eventId: "evt_123", + body: { + type: "payment.succeeded", + eventId: "evt_123", + linkId: "plink_123", + invoiceId: "INV-123", + amount: 12345, + currency: "usd", + paidInFull: true, + paymentMethod: "Credit/Debit Card (Visa ending in 4242)", + payerName: null, + payerEmail: "payer@example.com", + occurredAt: "2026-05-13T12:00:00.000Z", + }, + logger, + }); + }); + + test("drops the message when the link is missing", async () => { + ddbMock.on(QueryCommand).resolvesOnce({ Items: [] }); + + await stripeLinkSubscriberCallbackHandler(payload, metadata, logger as any); + + expect(deliverSubscriberCallbackMock).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith( + { linkId: "plink_123" }, + "Stripe link not found for subscriber callback; dropping message.", + ); + }); + + test("drops the message when callbacks are disabled before delivery", async () => { + ddbMock.on(QueryCommand).resolvesOnce({ + Items: [ + marshall({ callbackUrl: "https://callbacks.example.com/stripe" }), + ], + }); + + await stripeLinkSubscriberCallbackHandler(payload, metadata, logger as any); + + expect(deliverSubscriberCallbackMock).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith( + { linkId: "plink_123" }, + "Stripe link has no callbackUrl/signingSecret; dropping message.", + ); + }); + + test("drops blocked callback URLs without retrying", async () => { + ddbMock.on(QueryCommand).resolvesOnce({ + Items: [ + marshall({ + callbackUrl: "https://10.0.0.1/stripe", + signingSecret: "secret_123", + }), + ], + }); + deliverSubscriberCallbackMock.mockRejectedValueOnce( + new SubscriberCallbackBlockedError("blocked"), + ); + + await stripeLinkSubscriberCallbackHandler(payload, metadata, logger as any); + + expect(logger.error).toHaveBeenCalledWith( + { error: "blocked", linkId: "plink_123" }, + "Subscriber callback blocked; dropping (not retrying).", + ); + }); + + test("bubbles delivery failures so SQS retries", async () => { + ddbMock.on(QueryCommand).resolvesOnce({ + Items: [ + marshall({ + callbackUrl: "https://callbacks.example.com/stripe", + signingSecret: "secret_123", + }), + ], + }); + deliverSubscriberCallbackMock.mockRejectedValueOnce(new Error("retry me")); + + await expect( + stripeLinkSubscriberCallbackHandler(payload, metadata, logger as any), + ).rejects.toThrow("retry me"); + }); +}); diff --git a/tests/unit/sqs/subscriberCallback.test.ts b/tests/unit/sqs/subscriberCallback.test.ts new file mode 100644 index 000000000..6d1e0e5cc --- /dev/null +++ b/tests/unit/sqs/subscriberCallback.test.ts @@ -0,0 +1,140 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +const { lookupMock } = vi.hoisted(() => ({ + lookupMock: vi.fn(), +})); + +vi.mock("dns/promises", () => ({ + lookup: lookupMock, +})); + +import { + assertCallbackUrlIsExternal, + deliverSubscriberCallback, + signCallbackBody, + SubscriberCallbackBlockedError, +} from "../../../src/api/functions/subscriberCallback.js"; + +describe("subscriber callback delivery helpers", () => { + const logger = { + info: vi.fn(), + warn: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + test("signCallbackBody signs timestamp dot raw body with HMAC-SHA256", () => { + const body = JSON.stringify({ + type: "payment.succeeded", + eventId: "evt_123", + }); + + expect( + signCallbackBody({ + body, + signingSecret: "secret_123", + timestamp: 1700000000, + }), + ).toBe("2dde5702042948c1481db06d6f508e3671bb1b9519aa99baa8d174911323725e"); + }); + + test("assertCallbackUrlIsExternal rejects non-HTTPS URLs", async () => { + await expect( + assertCallbackUrlIsExternal("http://example.com/callback"), + ).rejects.toThrow(SubscriberCallbackBlockedError); + expect(lookupMock).not.toHaveBeenCalled(); + }); + + test("assertCallbackUrlIsExternal rejects private IP literals", async () => { + await expect( + assertCallbackUrlIsExternal("https://10.0.0.1/callback"), + ).rejects.toThrow("private IPv4"); + await expect( + assertCallbackUrlIsExternal("https://[fc00::1]/callback"), + ).rejects.toThrow("private IPv6"); + expect(lookupMock).not.toHaveBeenCalled(); + }); + + test("assertCallbackUrlIsExternal rejects hostnames that resolve privately", async () => { + lookupMock.mockResolvedValueOnce([{ address: "192.168.1.5", family: 4 }]); + + await expect( + assertCallbackUrlIsExternal("https://callbacks.example.com/stripe"), + ).rejects.toThrow("private IPv4"); + }); + + test("assertCallbackUrlIsExternal accepts hostnames that resolve publicly", async () => { + lookupMock.mockResolvedValueOnce([ + { address: "93.184.216.34", family: 4 }, + { address: "2606:2800:220:1:248:1893:25c8:1946", family: 6 }, + ]); + + await expect( + assertCallbackUrlIsExternal("https://callbacks.example.com/stripe"), + ).resolves.toBeUndefined(); + }); + + test("deliverSubscriberCallback posts signed JSON and logs success", async () => { + lookupMock.mockResolvedValueOnce([{ address: "93.184.216.34", family: 4 }]); + vi.setSystemTime(new Date("2023-11-14T22:13:20.000Z")); + vi.useFakeTimers({ shouldAdvanceTime: true }); + const fetchMock = vi.fn(async () => new Response(null, { status: 204 })); + vi.stubGlobal("fetch", fetchMock); + + await deliverSubscriberCallback({ + callbackUrl: "https://callbacks.example.com/stripe", + signingSecret: "secret_123", + body: { type: "payment.succeeded", eventId: "evt_123" }, + eventId: "evt_123", + logger: logger as any, + }); + + expect(fetchMock).toHaveBeenCalledExactlyOnceWith( + "https://callbacks.example.com/stripe", + expect.objectContaining({ + method: "POST", + body: '{"type":"payment.succeeded","eventId":"evt_123"}', + headers: expect.objectContaining({ + "Content-Type": "application/json", + "X-ACM-Event-Id": "evt_123", + "X-ACM-Signature": + "t=1700000000,v1=2dde5702042948c1481db06d6f508e3671bb1b9519aa99baa8d174911323725e", + }), + }), + ); + expect(logger.info).toHaveBeenCalledWith( + expect.objectContaining({ status: 204, eventId: "evt_123" }), + "Subscriber callback delivered.", + ); + }); + + test("deliverSubscriberCallback throws on non-2xx so SQS can retry", async () => { + lookupMock.mockResolvedValueOnce([{ address: "93.184.216.34", family: 4 }]); + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("subscriber failed", { status: 500 })), + ); + + await expect( + deliverSubscriberCallback({ + callbackUrl: "https://callbacks.example.com/stripe", + signingSecret: "secret_123", + body: { type: "payment.failed", eventId: "evt_456" }, + eventId: "evt_456", + logger: logger as any, + }), + ).rejects.toThrow("Subscriber callback returned 500"); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ status: 500, eventId: "evt_456" }), + "Subscriber callback returned non-2xx; will retry.", + ); + }); +});