diff --git a/.changeset/payment-option-empty-fields.md b/.changeset/payment-option-empty-fields.md new file mode 100644 index 00000000..ca60103f --- /dev/null +++ b/.changeset/payment-option-empty-fields.md @@ -0,0 +1,10 @@ +--- +"@agentcommercekit/ack-pay": patch +--- + +Reject empty payment request and payment option identifiers. + +`paymentOptionSchema` accepted empty strings for `id`, `currency`, and +`recipient`, and `paymentRequestSchema` accepted an empty request `id`, even +though other fields such as `amount` already reject invalid values. Require a +non-empty string for these fields in both the valibot and zod schemas. diff --git a/packages/ack-pay/src/schemas.test.ts b/packages/ack-pay/src/schemas.test.ts index 802b45c8..baf38667 100644 --- a/packages/ack-pay/src/schemas.test.ts +++ b/packages/ack-pay/src/schemas.test.ts @@ -1,8 +1,14 @@ import * as v from "valibot" import { describe, expect, it } from "vitest" -import { paymentRequestSchema as valibotPaymentRequestSchema } from "./schemas/valibot" -import { paymentRequestSchema as zodPaymentRequestSchema } from "./schemas/zod" +import { + paymentOptionSchema as valibotPaymentOptionSchema, + paymentRequestSchema as valibotPaymentRequestSchema, +} from "./schemas/valibot" +import { + paymentOptionSchema as zodPaymentOptionSchema, + paymentRequestSchema as zodPaymentRequestSchema, +} from "./schemas/zod" const paymentRequest = { id: "test-payment-request-id", @@ -17,6 +23,46 @@ const paymentRequest = { ], } +const paymentOption = paymentRequest.paymentOptions[0] + +const validators = { + valibot: { + paymentRequest: (input: unknown) => + v.safeParse(valibotPaymentRequestSchema, input).success, + paymentOption: (input: unknown) => + v.safeParse(valibotPaymentOptionSchema, input).success, + }, + zod: { + paymentRequest: (input: unknown) => + zodPaymentRequestSchema.safeParse(input).success, + paymentOption: (input: unknown) => + zodPaymentOptionSchema.safeParse(input).success, + }, +} as const + +describe.each(Object.entries(validators))("%s payment schemas", (_, schema) => { + it.each(["id", "currency", "recipient"] as const)( + "rejects a payment option with an empty %s", + (field) => { + expect( + schema.paymentOption({ + ...paymentOption, + [field]: "", + }), + ).toBe(false) + }, + ) + + it("rejects a payment request with an empty id", () => { + expect( + schema.paymentRequest({ + ...paymentRequest, + id: "", + }), + ).toBe(false) + }) +}) + describe("paymentRequestSchema", () => { it("rejects invalid expiresAt strings instead of throwing", () => { const input = { diff --git a/packages/ack-pay/src/schemas/valibot.ts b/packages/ack-pay/src/schemas/valibot.ts index d97ed94d..e2c43c34 100644 --- a/packages/ack-pay/src/schemas/valibot.ts +++ b/packages/ack-pay/src/schemas/valibot.ts @@ -4,6 +4,8 @@ import * as v from "valibot" const urlOrDidUri = v.union([v.pipe(v.string(), v.url()), didUriSchema]) +const nonEmptyString = v.pipe(v.string(), v.minLength(1)) + const timestampSchema = v.pipe( v.union([v.date(), v.string()]), v.check((input) => !Number.isNaN(new Date(input).getTime()), "Invalid date"), @@ -11,18 +13,18 @@ const timestampSchema = v.pipe( ) export const paymentOptionSchema = v.object({ - id: v.string(), + id: nonEmptyString, amount: v.union([v.pipe(v.number(), v.integer(), v.gtValue(0)), v.string()]), decimals: v.pipe(v.number(), v.integer(), v.toMinValue(0)), - currency: v.string(), - recipient: v.string(), + currency: nonEmptyString, + recipient: nonEmptyString, network: v.optional(v.string()), paymentService: v.optional(urlOrDidUri), receiptService: v.optional(urlOrDidUri), }) export const paymentRequestSchema = v.object({ - id: v.string(), + id: nonEmptyString, description: v.optional(v.string()), serviceCallback: v.optional(v.pipe(v.string(), v.url())), expiresAt: v.optional(timestampSchema), diff --git a/packages/ack-pay/src/schemas/zod.ts b/packages/ack-pay/src/schemas/zod.ts index 0f676fcb..e6b77031 100644 --- a/packages/ack-pay/src/schemas/zod.ts +++ b/packages/ack-pay/src/schemas/zod.ts @@ -4,6 +4,8 @@ import * as z from "zod" const urlOrDidUri = z.union([z.url(), didUriSchema]) +const nonEmptyString = z.string().min(1) + const timestampSchema = z .union([z.date(), z.string()]) .transform((val, ctx) => { @@ -21,18 +23,18 @@ const timestampSchema = z }) export const paymentOptionSchema = z.object({ - id: z.string(), + id: nonEmptyString, amount: z.union([z.number().int().positive(), z.string()]), decimals: z.number().int().nonnegative(), - currency: z.string(), - recipient: z.string(), + currency: nonEmptyString, + recipient: nonEmptyString, network: z.string().optional(), paymentService: urlOrDidUri.optional(), receiptService: urlOrDidUri.optional(), }) export const paymentRequestSchema = z.object({ - id: z.string(), + id: nonEmptyString, description: z.string().optional(), serviceCallback: z.url().optional(), expiresAt: timestampSchema.optional(),