manicanldes-backend/test/helpers/fake-gateway.ts

63 lines
2.2 KiB
TypeScript

import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
import { z } from 'zod';
import type {
GatewayPort,
PaymentRequest,
RefundRequest,
} from '../../src/payments/gateway.contract';
import { commandHash } from '../../src/inventory/inventory.policy';
const captureSchema = z
.object({
eventId: z.uuid(),
paymentId: z.uuid(),
orderId: z.uuid(),
amountMinor: z.string().regex(/^[1-9]\d{0,15}$/),
currency: z.enum(['INR', 'USD', 'EUR', 'GBP']),
})
.strict();
// Deliberately confined to tests: this signature format is not a real provider protocol.
export class FakeGateway implements GatewayPort {
private readonly payments = new Map<
string,
{ hash: string; paymentId: string }
>();
private readonly refunds = new Map<
string,
{ hash: string; refundId: string }
>();
constructor(private readonly secret: string) {}
async createPayment(input: PaymentRequest) {
const hash = commandHash(input.orderId, input.amountMinor, input.currency);
const prior = this.payments.get(input.idempotencyKey);
if (prior && prior.hash !== hash)
throw new Error('Gateway idempotency conflict');
const result = prior ?? { hash, paymentId: randomUUID() };
this.payments.set(input.idempotencyKey, result);
return { paymentId: result.paymentId };
}
sign(raw: Buffer) {
return createHmac('sha256', this.secret).update(raw).digest('hex');
}
verifyCapture(raw: Buffer, signature: string) {
if (
raw.length > 32768 ||
!/^[a-f0-9]{64}$/.test(signature) ||
!timingSafeEqual(
Buffer.from(signature, 'hex'),
Buffer.from(this.sign(raw), 'hex'),
)
)
throw new Error('Invalid gateway signature');
return captureSchema.parse(JSON.parse(raw.toString('utf8')));
}
async requestRefund(input: RefundRequest) {
const hash = commandHash(input.paymentId, input.amountMinor);
const prior = this.refunds.get(input.idempotencyKey);
if (prior && prior.hash !== hash)
throw new Error('Refund idempotency conflict');
const result = prior ?? { hash, refundId: randomUUID() };
this.refunds.set(input.idempotencyKey, result);
return { refundId: result.refundId, status: 'PENDING' as const };
}
}