@86d-app/payments 0.0.4 → 0.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -0
- package/AGENTS.md +52 -45
- package/README.md +26 -5
- package/dist/__tests__/controllers.test.d.ts +2 -0
- package/dist/__tests__/controllers.test.d.ts.map +1 -0
- package/dist/__tests__/edge-cases.test.d.ts +2 -0
- package/dist/__tests__/edge-cases.test.d.ts.map +1 -0
- package/dist/__tests__/endpoint-security.test.d.ts +2 -0
- package/dist/__tests__/endpoint-security.test.d.ts.map +1 -0
- package/dist/__tests__/financial-safety.test.d.ts +2 -0
- package/dist/__tests__/financial-safety.test.d.ts.map +1 -0
- package/dist/__tests__/service-impl.test.d.ts +2 -0
- package/dist/__tests__/service-impl.test.d.ts.map +1 -0
- package/dist/admin/components/index.d.ts +2 -0
- package/dist/admin/components/index.d.ts.map +1 -0
- package/dist/admin/components/payments-admin.d.ts +2 -0
- package/dist/admin/components/payments-admin.d.ts.map +1 -0
- package/dist/admin/endpoints/create-refund.d.ts +20 -0
- package/dist/admin/endpoints/create-refund.d.ts.map +1 -0
- package/dist/admin/endpoints/get-intent.d.ts +16 -0
- package/dist/admin/endpoints/get-intent.d.ts.map +1 -0
- package/dist/admin/endpoints/index.d.ts +63 -0
- package/dist/admin/endpoints/index.d.ts.map +1 -0
- package/dist/admin/endpoints/list-intents.d.ts +22 -0
- package/dist/admin/endpoints/list-intents.d.ts.map +1 -0
- package/dist/admin/endpoints/list-refunds.d.ts +10 -0
- package/dist/admin/endpoints/list-refunds.d.ts.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/schema.d.ts +169 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/service-impl.d.ts +4 -0
- package/dist/service-impl.d.ts.map +1 -0
- package/dist/service.d.ts +125 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/store/endpoints/cancel-intent.d.ts +16 -0
- package/dist/store/endpoints/cancel-intent.d.ts.map +1 -0
- package/dist/store/endpoints/confirm-intent.d.ts +16 -0
- package/dist/store/endpoints/confirm-intent.d.ts.map +1 -0
- package/dist/store/endpoints/create-intent.d.ts +15 -0
- package/dist/store/endpoints/create-intent.d.ts.map +1 -0
- package/dist/store/endpoints/delete-method.d.ts +16 -0
- package/dist/store/endpoints/delete-method.d.ts.map +1 -0
- package/dist/store/endpoints/get-intent.d.ts +16 -0
- package/dist/store/endpoints/get-intent.d.ts.map +1 -0
- package/dist/store/endpoints/index.d.ts +83 -0
- package/dist/store/endpoints/index.d.ts.map +1 -0
- package/dist/store/endpoints/list-methods.d.ts +12 -0
- package/dist/store/endpoints/list-methods.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/__tests__/controllers.test.ts +1043 -0
- package/src/__tests__/edge-cases.test.ts +547 -0
- package/src/__tests__/endpoint-security.test.ts +360 -0
- package/src/__tests__/financial-safety.test.ts +576 -0
- package/src/__tests__/service-impl.test.ts +30 -5
- package/src/admin/endpoints/create-refund.ts +11 -6
- package/src/service-impl.ts +60 -0
- package/src/store/endpoints/cancel-intent.ts +20 -4
- package/src/store/endpoints/confirm-intent.ts +20 -4
- package/src/store/endpoints/create-intent.ts +18 -5
- package/src/store/endpoints/delete-method.ts +11 -1
- package/src/store/endpoints/get-intent.ts +1 -1
- package/src/store/endpoints/list-methods.ts +7 -5
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { createMockDataService } from "@86d-app/core/test-utils";
|
|
2
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
3
|
+
import { createPaymentController } from "../service-impl";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Security regression tests for payments endpoints.
|
|
7
|
+
*
|
|
8
|
+
* Payments handle sensitive financial data (card details, transaction amounts).
|
|
9
|
+
* These tests verify:
|
|
10
|
+
* - Customer isolation: listIntents / listPaymentMethods scoped by customerId
|
|
11
|
+
* - Ownership gaps: getIntent / getPaymentMethod expose data without customer check
|
|
12
|
+
* - Status transition guards: cancellation constraints
|
|
13
|
+
* - Refund validation: amount bounds, intent existence
|
|
14
|
+
* - Default payment method exclusivity per customer
|
|
15
|
+
* - Webhook lookup by providerIntentId
|
|
16
|
+
* - Non-existent resource handling
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
describe("payments endpoint security", () => {
|
|
20
|
+
let mockData: ReturnType<typeof createMockDataService>;
|
|
21
|
+
let controller: ReturnType<typeof createPaymentController>;
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
mockData = createMockDataService();
|
|
25
|
+
controller = createPaymentController(mockData);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// ── Customer Isolation ──────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
describe("customer isolation", () => {
|
|
31
|
+
it("listIntents scoped by customerId does not return other customers' intents", async () => {
|
|
32
|
+
await controller.createIntent({
|
|
33
|
+
amount: 5000,
|
|
34
|
+
customerId: "victim",
|
|
35
|
+
});
|
|
36
|
+
await controller.createIntent({
|
|
37
|
+
amount: 3000,
|
|
38
|
+
customerId: "victim",
|
|
39
|
+
});
|
|
40
|
+
await controller.createIntent({
|
|
41
|
+
amount: 1000,
|
|
42
|
+
customerId: "attacker",
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const attackerIntents = await controller.listIntents({
|
|
46
|
+
customerId: "attacker",
|
|
47
|
+
});
|
|
48
|
+
expect(attackerIntents).toHaveLength(1);
|
|
49
|
+
expect(attackerIntents[0].customerId).toBe("attacker");
|
|
50
|
+
expect(attackerIntents[0].amount).toBe(1000);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("listIntents without customerId filter returns all intents (admin only)", async () => {
|
|
54
|
+
await controller.createIntent({
|
|
55
|
+
amount: 1000,
|
|
56
|
+
customerId: "cust_a",
|
|
57
|
+
});
|
|
58
|
+
await controller.createIntent({
|
|
59
|
+
amount: 2000,
|
|
60
|
+
customerId: "cust_b",
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const all = await controller.listIntents();
|
|
64
|
+
expect(all).toHaveLength(2);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("listPaymentMethods scoped by customerId does not leak other customers' methods", async () => {
|
|
68
|
+
await controller.savePaymentMethod({
|
|
69
|
+
customerId: "victim",
|
|
70
|
+
providerMethodId: "pm_victim_1",
|
|
71
|
+
type: "card",
|
|
72
|
+
last4: "4242",
|
|
73
|
+
});
|
|
74
|
+
await controller.savePaymentMethod({
|
|
75
|
+
customerId: "attacker",
|
|
76
|
+
providerMethodId: "pm_attacker_1",
|
|
77
|
+
type: "card",
|
|
78
|
+
last4: "1234",
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const attackerMethods = await controller.listPaymentMethods("attacker");
|
|
82
|
+
expect(attackerMethods).toHaveLength(1);
|
|
83
|
+
expect(attackerMethods[0].customerId).toBe("attacker");
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// ── Ownership Gaps (documented) ─────────────────────────────────
|
|
88
|
+
|
|
89
|
+
describe("ownership gaps — endpoints must enforce", () => {
|
|
90
|
+
it("getIntent returns intent regardless of customerId (no ownership check)", async () => {
|
|
91
|
+
const intent = await controller.createIntent({
|
|
92
|
+
amount: 9999,
|
|
93
|
+
customerId: "victim",
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Controller does NOT verify the caller owns this intent
|
|
97
|
+
const result = await controller.getIntent(intent.id);
|
|
98
|
+
expect(result).not.toBeNull();
|
|
99
|
+
expect(result?.customerId).toBe("victim");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("getPaymentMethod returns method regardless of customerId (no ownership check)", async () => {
|
|
103
|
+
const method = await controller.savePaymentMethod({
|
|
104
|
+
customerId: "victim",
|
|
105
|
+
providerMethodId: "pm_secret",
|
|
106
|
+
type: "card",
|
|
107
|
+
last4: "9999",
|
|
108
|
+
brand: "visa",
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Controller does NOT verify the caller owns this method
|
|
112
|
+
const result = await controller.getPaymentMethod(method.id);
|
|
113
|
+
expect(result).not.toBeNull();
|
|
114
|
+
expect(result?.customerId).toBe("victim");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("deletePaymentMethod deletes without ownership check", async () => {
|
|
118
|
+
const method = await controller.savePaymentMethod({
|
|
119
|
+
customerId: "victim",
|
|
120
|
+
providerMethodId: "pm_to_delete",
|
|
121
|
+
type: "card",
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Any caller can delete — endpoint must enforce ownership
|
|
125
|
+
const deleted = await controller.deletePaymentMethod(method.id);
|
|
126
|
+
expect(deleted).toBe(true);
|
|
127
|
+
|
|
128
|
+
const afterDelete = await controller.getPaymentMethod(method.id);
|
|
129
|
+
expect(afterDelete).toBeNull();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// ── Intent Status Transitions ───────────────────────────────────
|
|
134
|
+
|
|
135
|
+
describe("intent status transitions", () => {
|
|
136
|
+
it("cancelIntent transitions pending intent to cancelled", async () => {
|
|
137
|
+
const intent = await controller.createIntent({ amount: 5000 });
|
|
138
|
+
expect(intent.status).toBe("pending");
|
|
139
|
+
|
|
140
|
+
const cancelled = await controller.cancelIntent(intent.id);
|
|
141
|
+
expect(cancelled?.status).toBe("cancelled");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("cancelIntent on already-cancelled intent returns the intent unchanged", async () => {
|
|
145
|
+
const intent = await controller.createIntent({ amount: 5000 });
|
|
146
|
+
await controller.cancelIntent(intent.id);
|
|
147
|
+
|
|
148
|
+
const result = await controller.cancelIntent(intent.id);
|
|
149
|
+
expect(result?.status).toBe("cancelled");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("confirmIntent transitions pending to succeeded without provider", async () => {
|
|
153
|
+
const intent = await controller.createIntent({ amount: 2500 });
|
|
154
|
+
expect(intent.status).toBe("pending");
|
|
155
|
+
|
|
156
|
+
const confirmed = await controller.confirmIntent(intent.id);
|
|
157
|
+
expect(confirmed?.status).toBe("succeeded");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("confirmIntent on already-succeeded intent returns it unchanged", async () => {
|
|
161
|
+
const intent = await controller.createIntent({ amount: 2500 });
|
|
162
|
+
await controller.confirmIntent(intent.id);
|
|
163
|
+
|
|
164
|
+
const result = await controller.confirmIntent(intent.id);
|
|
165
|
+
expect(result?.status).toBe("succeeded");
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// ── Refund Validation ───────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
describe("refund validation", () => {
|
|
172
|
+
it("createRefund throws when intent does not exist", async () => {
|
|
173
|
+
await expect(
|
|
174
|
+
controller.createRefund({ intentId: "nonexistent_intent" }),
|
|
175
|
+
).rejects.toThrow("Payment intent not found");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("createRefund defaults to full intent amount when no amount specified", async () => {
|
|
179
|
+
const intent = await controller.createIntent({ amount: 7500 });
|
|
180
|
+
await controller.confirmIntent(intent.id);
|
|
181
|
+
const refund = await controller.createRefund({
|
|
182
|
+
intentId: intent.id,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
expect(refund.amount).toBe(7500);
|
|
186
|
+
expect(refund.paymentIntentId).toBe(intent.id);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("createRefund marks intent as refunded", async () => {
|
|
190
|
+
const intent = await controller.createIntent({ amount: 5000 });
|
|
191
|
+
await controller.confirmIntent(intent.id);
|
|
192
|
+
await controller.createRefund({ intentId: intent.id });
|
|
193
|
+
|
|
194
|
+
const updated = await controller.getIntent(intent.id);
|
|
195
|
+
expect(updated?.status).toBe("refunded");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("listRefunds scoped to intentId does not leak refunds from other intents", async () => {
|
|
199
|
+
const intent1 = await controller.createIntent({ amount: 1000 });
|
|
200
|
+
await controller.confirmIntent(intent1.id);
|
|
201
|
+
const intent2 = await controller.createIntent({ amount: 2000 });
|
|
202
|
+
await controller.confirmIntent(intent2.id);
|
|
203
|
+
|
|
204
|
+
await controller.createRefund({
|
|
205
|
+
intentId: intent1.id,
|
|
206
|
+
reason: "defective",
|
|
207
|
+
});
|
|
208
|
+
await controller.createRefund({
|
|
209
|
+
intentId: intent2.id,
|
|
210
|
+
reason: "wrong item",
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const refunds1 = await controller.listRefunds(intent1.id);
|
|
214
|
+
expect(refunds1).toHaveLength(1);
|
|
215
|
+
expect(refunds1[0].paymentIntentId).toBe(intent1.id);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// ── Default Payment Method ──────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
describe("default payment method exclusivity", () => {
|
|
222
|
+
it("saving a new default method clears the previous default for the same customer", async () => {
|
|
223
|
+
const first = await controller.savePaymentMethod({
|
|
224
|
+
customerId: "cust_1",
|
|
225
|
+
providerMethodId: "pm_first",
|
|
226
|
+
type: "card",
|
|
227
|
+
isDefault: true,
|
|
228
|
+
});
|
|
229
|
+
expect(first.isDefault).toBe(true);
|
|
230
|
+
|
|
231
|
+
const second = await controller.savePaymentMethod({
|
|
232
|
+
customerId: "cust_1",
|
|
233
|
+
providerMethodId: "pm_second",
|
|
234
|
+
type: "card",
|
|
235
|
+
isDefault: true,
|
|
236
|
+
});
|
|
237
|
+
expect(second.isDefault).toBe(true);
|
|
238
|
+
|
|
239
|
+
// First method should no longer be default
|
|
240
|
+
const firstAfter = await controller.getPaymentMethod(first.id);
|
|
241
|
+
expect(firstAfter?.isDefault).toBe(false);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("setting default for one customer does not affect another customer's default", async () => {
|
|
245
|
+
const custADefault = await controller.savePaymentMethod({
|
|
246
|
+
customerId: "cust_a",
|
|
247
|
+
providerMethodId: "pm_a",
|
|
248
|
+
type: "card",
|
|
249
|
+
isDefault: true,
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// Save a default for a different customer
|
|
253
|
+
await controller.savePaymentMethod({
|
|
254
|
+
customerId: "cust_b",
|
|
255
|
+
providerMethodId: "pm_b",
|
|
256
|
+
type: "card",
|
|
257
|
+
isDefault: true,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// Customer A's default should remain unchanged
|
|
261
|
+
const custAAfter = await controller.getPaymentMethod(custADefault.id);
|
|
262
|
+
expect(custAAfter?.isDefault).toBe(true);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// ── Webhook Handling ────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
describe("webhook handling", () => {
|
|
269
|
+
it("handleWebhookEvent looks up intent by providerIntentId and updates status", async () => {
|
|
270
|
+
const intent = await controller.createIntent({
|
|
271
|
+
amount: 4000,
|
|
272
|
+
customerId: "cust_1",
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// Simulate provider assigning a providerIntentId via direct data update
|
|
276
|
+
await mockData.upsert("paymentIntent", intent.id, {
|
|
277
|
+
...intent,
|
|
278
|
+
providerIntentId: "pi_provider_123",
|
|
279
|
+
} as unknown as Record<string, unknown>);
|
|
280
|
+
|
|
281
|
+
const result = await controller.handleWebhookEvent({
|
|
282
|
+
providerIntentId: "pi_provider_123",
|
|
283
|
+
status: "succeeded",
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
expect(result).not.toBeNull();
|
|
287
|
+
expect(result?.status).toBe("succeeded");
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("handleWebhookEvent returns null for unknown providerIntentId", async () => {
|
|
291
|
+
const result = await controller.handleWebhookEvent({
|
|
292
|
+
providerIntentId: "pi_nonexistent",
|
|
293
|
+
status: "succeeded",
|
|
294
|
+
});
|
|
295
|
+
expect(result).toBeNull();
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("handleWebhookRefund creates refund and marks intent as refunded", async () => {
|
|
299
|
+
const intent = await controller.createIntent({ amount: 6000 });
|
|
300
|
+
|
|
301
|
+
await mockData.upsert("paymentIntent", intent.id, {
|
|
302
|
+
...intent,
|
|
303
|
+
providerIntentId: "pi_webhook_456",
|
|
304
|
+
} as unknown as Record<string, unknown>);
|
|
305
|
+
|
|
306
|
+
const result = await controller.handleWebhookRefund({
|
|
307
|
+
providerIntentId: "pi_webhook_456",
|
|
308
|
+
providerRefundId: "re_webhook_1",
|
|
309
|
+
amount: 6000,
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
expect(result).not.toBeNull();
|
|
313
|
+
expect(result?.intent.status).toBe("refunded");
|
|
314
|
+
expect(result?.refund.amount).toBe(6000);
|
|
315
|
+
expect(result?.refund.providerRefundId).toBe("re_webhook_1");
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("handleWebhookRefund returns null for unknown providerIntentId", async () => {
|
|
319
|
+
const result = await controller.handleWebhookRefund({
|
|
320
|
+
providerIntentId: "pi_ghost",
|
|
321
|
+
providerRefundId: "re_ghost",
|
|
322
|
+
});
|
|
323
|
+
expect(result).toBeNull();
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// ── Non-existent Resources ──────────────────────────────────────
|
|
328
|
+
|
|
329
|
+
describe("non-existent resources", () => {
|
|
330
|
+
it("getIntent returns null for unknown id", async () => {
|
|
331
|
+
const result = await controller.getIntent("nonexistent");
|
|
332
|
+
expect(result).toBeNull();
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("getPaymentMethod returns null for unknown id", async () => {
|
|
336
|
+
const result = await controller.getPaymentMethod("nonexistent");
|
|
337
|
+
expect(result).toBeNull();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("getRefund returns null for unknown id", async () => {
|
|
341
|
+
const result = await controller.getRefund("nonexistent");
|
|
342
|
+
expect(result).toBeNull();
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("cancelIntent returns null for unknown id", async () => {
|
|
346
|
+
const result = await controller.cancelIntent("nonexistent");
|
|
347
|
+
expect(result).toBeNull();
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("confirmIntent returns null for unknown id", async () => {
|
|
351
|
+
const result = await controller.confirmIntent("nonexistent");
|
|
352
|
+
expect(result).toBeNull();
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("deletePaymentMethod returns false for unknown id", async () => {
|
|
356
|
+
const result = await controller.deletePaymentMethod("nonexistent");
|
|
357
|
+
expect(result).toBe(false);
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
});
|