payment-kit 1.28.0 → 1.29.0
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/api/src/crons/index.ts +22 -0
- package/api/src/crons/retry-pending-events.ts +58 -0
- package/api/src/integrations/app-store/apple-root-certs.ts +26 -0
- package/api/src/integrations/app-store/client.ts +369 -0
- package/api/src/integrations/app-store/handlers/index.ts +46 -0
- package/api/src/integrations/app-store/handlers/subscription.ts +635 -0
- package/api/src/integrations/app-store/node-apple-receipt-verify.d.ts +17 -0
- package/api/src/integrations/app-store/notification-routing.ts +18 -0
- package/api/src/integrations/app-store/signed-data-verifier.ts +150 -0
- package/api/src/integrations/google-play/client.ts +276 -0
- package/api/src/integrations/google-play/handlers/index.ts +69 -0
- package/api/src/integrations/google-play/handlers/subscription.ts +565 -0
- package/api/src/integrations/google-play/handlers/voided.ts +106 -0
- package/api/src/integrations/google-play/setup.ts +43 -0
- package/api/src/integrations/google-play/verify.ts +251 -0
- package/api/src/integrations/iap-reconcile.ts +415 -0
- package/api/src/libs/audit.ts +38 -8
- package/api/src/libs/entitlement.ts +399 -0
- package/api/src/libs/env.ts +2 -0
- package/api/src/libs/security.ts +51 -0
- package/api/src/libs/subscription.ts +13 -1
- package/api/src/libs/util.ts +13 -0
- package/api/src/queues/event.ts +25 -19
- package/api/src/queues/webhook.ts +12 -2
- package/api/src/routes/entitlements.ts +105 -0
- package/api/src/routes/events.ts +2 -2
- package/api/src/routes/index.ts +12 -2
- package/api/src/routes/integrations/app-store.ts +267 -0
- package/api/src/routes/integrations/google-play.ts +324 -0
- package/api/src/routes/payment-methods.ts +130 -0
- package/api/src/store/migrations/20260526-iap-foundation.ts +105 -0
- package/api/src/store/models/customer.ts +14 -0
- package/api/src/store/models/entitlement-grant.ts +118 -0
- package/api/src/store/models/entitlement-product.ts +48 -0
- package/api/src/store/models/entitlement.ts +86 -0
- package/api/src/store/models/index.ts +9 -0
- package/api/src/store/models/invoice.ts +20 -0
- package/api/src/store/models/payment-method.ts +62 -1
- package/api/src/store/models/refund.ts +10 -0
- package/api/src/store/models/subscription.ts +14 -0
- package/api/src/store/models/types.ts +32 -0
- package/api/tests/integrations/app-store/client.spec.ts +335 -0
- package/api/tests/integrations/app-store/handlers.spec.ts +480 -0
- package/api/tests/integrations/app-store/notifications.spec.ts +381 -0
- package/api/tests/integrations/app-store/signed-data-verifier.spec.ts +72 -0
- package/api/tests/integrations/app-store/webhook-routing.spec.ts +27 -0
- package/api/tests/integrations/google-play/handlers.spec.ts +341 -0
- package/api/tests/integrations/google-play/verify.spec.ts +215 -0
- package/api/tests/integrations/iap-reconcile.spec.ts +237 -0
- package/api/tests/libs/entitlement.spec.ts +347 -0
- package/blocklet.yml +1 -1
- package/cloudflare/migrations/0004_iap_foundation.sql +72 -0
- package/cloudflare/migrations/0005_iap_tenant_backfill.sql +112 -0
- package/cloudflare/run-build.js +1 -0
- package/cloudflare/shims/blocklet-sdk/verify-session.ts +44 -0
- package/cloudflare/shims/queue.ts +28 -2
- package/cloudflare/shims/sequelize-d1/model.ts +19 -0
- package/cloudflare/shims/sequelize-d1/operators.ts +14 -1
- package/cloudflare/tests/shims/queue-delayed-persist.spec.ts +87 -0
- package/cloudflare/worker.ts +59 -4
- package/cloudflare/wrangler.jsonc +7 -1
- package/cloudflare/wrangler.staging.json +2 -1
- package/package.json +10 -6
- package/scripts/seed-google-play.ts +79 -0
- package/src/components/payment-method/app-store.tsx +103 -0
- package/src/components/payment-method/form.tsx +7 -1
- package/src/components/payment-method/google-play.tsx +85 -0
- package/src/components/subscription/list.tsx +20 -0
- package/src/locales/en.tsx +63 -0
- package/src/locales/zh.tsx +63 -0
- package/src/pages/admin/billing/subscriptions/detail.tsx +80 -0
- package/src/pages/admin/customers/customers/detail.tsx +6 -0
- package/src/pages/admin/settings/payment-methods/create.tsx +12 -0
- package/src/pages/admin/settings/payment-methods/index.tsx +1 -1
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
/* eslint-disable require-await, import/first */
|
|
2
|
+
// Unit tests for App Store Server Notifications V2 dispatch + state machine.
|
|
3
|
+
// Models / audit / client are fully mocked — DB is not touched.
|
|
4
|
+
|
|
5
|
+
process.env.APP_STORE_SKIP_SIGNATURE_VERIFY = 'true';
|
|
6
|
+
|
|
7
|
+
import handleAppStoreNotification from '../../../src/integrations/app-store/handlers';
|
|
8
|
+
import { handleAppStoreSubscriptionEvent } from '../../../src/integrations/app-store/handlers/subscription';
|
|
9
|
+
import type {
|
|
10
|
+
AppStoreClient,
|
|
11
|
+
AppStoreNotificationPayload,
|
|
12
|
+
AppStoreNotificationType,
|
|
13
|
+
AppStoreTransactionPayload,
|
|
14
|
+
} from '../../../src/integrations/app-store/client';
|
|
15
|
+
|
|
16
|
+
const mockSubscriptionFindOne: jest.Mock<any, any[]> = jest.fn();
|
|
17
|
+
const mockSubscriptionCreate: jest.Mock<any, any[]> = jest.fn();
|
|
18
|
+
const mockCustomerFindOne: jest.Mock<any, any[]> = jest.fn();
|
|
19
|
+
const mockPriceFindOne: jest.Mock<any, any[]> = jest.fn();
|
|
20
|
+
const mockPaymentMethodFindOne: jest.Mock<any, any[]> = jest.fn();
|
|
21
|
+
const mockCreateEvent: jest.Mock<Promise<undefined>, any[]> = jest.fn(async () => undefined);
|
|
22
|
+
|
|
23
|
+
jest.mock('../../../src/store/models', () => ({
|
|
24
|
+
Subscription: {
|
|
25
|
+
findOne: (...args: any[]) => mockSubscriptionFindOne(...args),
|
|
26
|
+
create: (...args: any[]) => mockSubscriptionCreate(...args),
|
|
27
|
+
},
|
|
28
|
+
Customer: {
|
|
29
|
+
findOne: (...args: any[]) => mockCustomerFindOne(...args),
|
|
30
|
+
},
|
|
31
|
+
Price: { findOne: (...args: any[]) => mockPriceFindOne(...args) },
|
|
32
|
+
PaymentMethod: {
|
|
33
|
+
findOne: (...args: any[]) => mockPaymentMethodFindOne(...args),
|
|
34
|
+
},
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
jest.mock('../../../src/libs/audit', () => ({
|
|
38
|
+
createEvent: (...args: any[]) => mockCreateEvent(...args),
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
const baseTransaction: AppStoreTransactionPayload = {
|
|
42
|
+
transactionId: 'txn_1',
|
|
43
|
+
originalTransactionId: 'orig_1',
|
|
44
|
+
productId: 'pro_monthly',
|
|
45
|
+
bundleId: 'com.example.app',
|
|
46
|
+
environment: 'Sandbox',
|
|
47
|
+
expiresDate: Date.now() + 30 * 24 * 60 * 60 * 1000,
|
|
48
|
+
purchaseDate: Date.now() - 60_000,
|
|
49
|
+
appAccountToken: 'uuid-1',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const makeNotification = (
|
|
53
|
+
type: AppStoreNotificationType,
|
|
54
|
+
overrides: Partial<AppStoreNotificationPayload> = {}
|
|
55
|
+
): AppStoreNotificationPayload => ({
|
|
56
|
+
notificationType: type,
|
|
57
|
+
notificationUUID: 'uuid-notif',
|
|
58
|
+
version: '2.0',
|
|
59
|
+
signedDate: Date.now(),
|
|
60
|
+
data: {
|
|
61
|
+
bundleId: 'com.example.app',
|
|
62
|
+
environment: 'Sandbox',
|
|
63
|
+
signedTransactionInfo: 'inner-jws-placeholder',
|
|
64
|
+
},
|
|
65
|
+
...overrides,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const makeSubStub = (overrides: Record<string, any> = {}) => {
|
|
69
|
+
const update = jest.fn(async (patch: any) => Object.assign(stub, patch));
|
|
70
|
+
const stub: any = {
|
|
71
|
+
id: 'sub_test',
|
|
72
|
+
status: 'active',
|
|
73
|
+
cancel_at_period_end: false,
|
|
74
|
+
current_period_end: 1700000000,
|
|
75
|
+
payment_details: { app_store: { original_transaction_id: 'orig_1', product_id: 'pro_monthly' } },
|
|
76
|
+
metadata: {},
|
|
77
|
+
update,
|
|
78
|
+
...overrides,
|
|
79
|
+
};
|
|
80
|
+
return stub;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const makeClient = (overrides: Partial<AppStoreClient> = {}): AppStoreClient =>
|
|
84
|
+
({
|
|
85
|
+
bundleId: 'com.example.app',
|
|
86
|
+
environment: 'sandbox',
|
|
87
|
+
verifyJwsTransaction: jest.fn(async () => baseTransaction),
|
|
88
|
+
...overrides,
|
|
89
|
+
}) as unknown as AppStoreClient;
|
|
90
|
+
|
|
91
|
+
beforeEach(() => {
|
|
92
|
+
mockSubscriptionFindOne.mockReset();
|
|
93
|
+
mockSubscriptionCreate.mockReset();
|
|
94
|
+
mockCustomerFindOne.mockReset();
|
|
95
|
+
mockPriceFindOne.mockReset().mockResolvedValue(null);
|
|
96
|
+
mockPaymentMethodFindOne.mockReset();
|
|
97
|
+
mockCreateEvent.mockReset().mockImplementation(async () => undefined);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('handleAppStoreNotification dispatch', () => {
|
|
101
|
+
it('returns silently for TEST notification', async () => {
|
|
102
|
+
const client = makeClient();
|
|
103
|
+
await handleAppStoreNotification(makeNotification('TEST'), client);
|
|
104
|
+
expect(client.verifyJwsTransaction).not.toHaveBeenCalled();
|
|
105
|
+
expect(mockSubscriptionFindOne).not.toHaveBeenCalled();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('returns silently when signedTransactionInfo is absent', async () => {
|
|
109
|
+
const client = makeClient();
|
|
110
|
+
const notif = makeNotification('DID_RENEW', {
|
|
111
|
+
data: { bundleId: 'com.example.app', environment: 'Sandbox' },
|
|
112
|
+
});
|
|
113
|
+
await handleAppStoreNotification(notif, client);
|
|
114
|
+
expect(client.verifyJwsTransaction).not.toHaveBeenCalled();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('decodes inner JWS and forwards to state machine for actionable notifications', async () => {
|
|
118
|
+
mockSubscriptionFindOne.mockResolvedValue(makeSubStub());
|
|
119
|
+
const client = makeClient();
|
|
120
|
+
await handleAppStoreNotification(makeNotification('DID_RENEW'), client);
|
|
121
|
+
expect(client.verifyJwsTransaction).toHaveBeenCalledWith('inner-jws-placeholder');
|
|
122
|
+
expect(mockSubscriptionFindOne).toHaveBeenCalled();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('SUBSCRIBED', () => {
|
|
127
|
+
it('is idempotent — does nothing when local Subscription already exists', async () => {
|
|
128
|
+
const existing = makeSubStub({ id: 'sub_existing' });
|
|
129
|
+
mockSubscriptionFindOne.mockResolvedValue(existing);
|
|
130
|
+
|
|
131
|
+
await handleAppStoreSubscriptionEvent({
|
|
132
|
+
notification: makeNotification('SUBSCRIBED'),
|
|
133
|
+
transaction: baseTransaction,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
expect(mockSubscriptionCreate).not.toHaveBeenCalled();
|
|
137
|
+
expect(existing.update).not.toHaveBeenCalled();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('logs orphan warning when transaction has no appAccountToken', async () => {
|
|
141
|
+
mockSubscriptionFindOne.mockResolvedValue(null);
|
|
142
|
+
|
|
143
|
+
await handleAppStoreSubscriptionEvent({
|
|
144
|
+
notification: makeNotification('SUBSCRIBED'),
|
|
145
|
+
transaction: { ...baseTransaction, appAccountToken: undefined },
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
expect(mockCustomerFindOne).not.toHaveBeenCalled();
|
|
149
|
+
expect(mockSubscriptionCreate).not.toHaveBeenCalled();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('logs orphan warning when no Customer matches appAccountToken', async () => {
|
|
153
|
+
mockSubscriptionFindOne.mockResolvedValue(null);
|
|
154
|
+
mockCustomerFindOne.mockResolvedValue(null);
|
|
155
|
+
|
|
156
|
+
await handleAppStoreSubscriptionEvent({
|
|
157
|
+
notification: makeNotification('SUBSCRIBED'),
|
|
158
|
+
transaction: baseTransaction,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
expect(mockCustomerFindOne).toHaveBeenCalledWith({ where: { app_store_uuid: 'uuid-1' } });
|
|
162
|
+
expect(mockSubscriptionCreate).not.toHaveBeenCalled();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('creates Subscription when Customer + PaymentMethod both found', async () => {
|
|
166
|
+
mockSubscriptionFindOne.mockResolvedValue(null);
|
|
167
|
+
mockCustomerFindOne.mockResolvedValue({ id: 'cus_1', livemode: false });
|
|
168
|
+
mockPaymentMethodFindOne.mockResolvedValue({ id: 'pm_1', default_currency_id: 'cur_1' });
|
|
169
|
+
mockSubscriptionCreate.mockResolvedValue({ id: 'sub_new' });
|
|
170
|
+
|
|
171
|
+
await handleAppStoreSubscriptionEvent({
|
|
172
|
+
notification: makeNotification('SUBSCRIBED', { subtype: 'INITIAL_BUY' }),
|
|
173
|
+
transaction: baseTransaction,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
expect(mockSubscriptionCreate).toHaveBeenCalledWith(
|
|
177
|
+
expect.objectContaining({
|
|
178
|
+
channel: 'app_store',
|
|
179
|
+
environment: 'sandbox',
|
|
180
|
+
status: 'active',
|
|
181
|
+
customer_id: 'cus_1',
|
|
182
|
+
default_payment_method_id: 'pm_1',
|
|
183
|
+
})
|
|
184
|
+
);
|
|
185
|
+
expect(mockCreateEvent).toHaveBeenCalledWith(
|
|
186
|
+
'Subscription',
|
|
187
|
+
'customer.subscription.started',
|
|
188
|
+
expect.objectContaining({ id: 'sub_new' })
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe('DID_RENEW', () => {
|
|
194
|
+
it('updates current_period_end + emits customer.subscription.started', async () => {
|
|
195
|
+
const sub = makeSubStub();
|
|
196
|
+
mockSubscriptionFindOne.mockResolvedValue(sub);
|
|
197
|
+
const newExpiryMs = Date.now() + 60 * 24 * 60 * 60 * 1000;
|
|
198
|
+
|
|
199
|
+
await handleAppStoreSubscriptionEvent({
|
|
200
|
+
notification: makeNotification('DID_RENEW'),
|
|
201
|
+
transaction: { ...baseTransaction, expiresDate: newExpiryMs },
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
expect(sub.update).toHaveBeenCalledWith(
|
|
205
|
+
expect.objectContaining({
|
|
206
|
+
status: 'active',
|
|
207
|
+
current_period_end: Math.floor(newExpiryMs / 1000),
|
|
208
|
+
})
|
|
209
|
+
);
|
|
210
|
+
expect(mockCreateEvent).toHaveBeenCalledWith('Subscription', 'customer.subscription.started', sub);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe('EXPIRED / GRACE_PERIOD_EXPIRED', () => {
|
|
215
|
+
it.each(['EXPIRED', 'GRACE_PERIOD_EXPIRED'] as AppStoreNotificationType[])(
|
|
216
|
+
'marks canceled + emits deleted (type=%s)',
|
|
217
|
+
async (type) => {
|
|
218
|
+
const sub = makeSubStub();
|
|
219
|
+
mockSubscriptionFindOne.mockResolvedValue(sub);
|
|
220
|
+
|
|
221
|
+
await handleAppStoreSubscriptionEvent({
|
|
222
|
+
notification: makeNotification(type),
|
|
223
|
+
transaction: baseTransaction,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
expect(sub.update).toHaveBeenCalledWith(expect.objectContaining({ status: 'canceled' }));
|
|
227
|
+
expect(mockCreateEvent).toHaveBeenCalledWith('Subscription', 'customer.subscription.deleted', sub);
|
|
228
|
+
}
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
it('is idempotent when already canceled', async () => {
|
|
232
|
+
const sub = makeSubStub({ status: 'canceled' });
|
|
233
|
+
mockSubscriptionFindOne.mockResolvedValue(sub);
|
|
234
|
+
|
|
235
|
+
await handleAppStoreSubscriptionEvent({
|
|
236
|
+
notification: makeNotification('EXPIRED'),
|
|
237
|
+
transaction: baseTransaction,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
expect(sub.update).not.toHaveBeenCalled();
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe('DID_FAIL_TO_RENEW', () => {
|
|
245
|
+
it('marks past_due', async () => {
|
|
246
|
+
const sub = makeSubStub();
|
|
247
|
+
mockSubscriptionFindOne.mockResolvedValue(sub);
|
|
248
|
+
|
|
249
|
+
await handleAppStoreSubscriptionEvent({
|
|
250
|
+
notification: makeNotification('DID_FAIL_TO_RENEW'),
|
|
251
|
+
transaction: baseTransaction,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
expect(sub.update).toHaveBeenCalledWith({ status: 'past_due' });
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
describe('DID_CHANGE_RENEWAL_STATUS', () => {
|
|
259
|
+
it('AUTO_RENEW_DISABLED schedules cancel_at_period_end', async () => {
|
|
260
|
+
const sub = makeSubStub();
|
|
261
|
+
mockSubscriptionFindOne.mockResolvedValue(sub);
|
|
262
|
+
|
|
263
|
+
await handleAppStoreSubscriptionEvent({
|
|
264
|
+
notification: makeNotification('DID_CHANGE_RENEWAL_STATUS', { subtype: 'AUTO_RENEW_DISABLED' }),
|
|
265
|
+
transaction: baseTransaction,
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
expect(sub.update).toHaveBeenCalledWith({ cancel_at_period_end: true });
|
|
269
|
+
expect(mockCreateEvent).not.toHaveBeenCalledWith(
|
|
270
|
+
'Subscription',
|
|
271
|
+
'customer.subscription.deleted',
|
|
272
|
+
expect.anything()
|
|
273
|
+
);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('AUTO_RENEW_ENABLED un-schedules cancel', async () => {
|
|
277
|
+
const sub = makeSubStub({ cancel_at_period_end: true });
|
|
278
|
+
mockSubscriptionFindOne.mockResolvedValue(sub);
|
|
279
|
+
|
|
280
|
+
await handleAppStoreSubscriptionEvent({
|
|
281
|
+
notification: makeNotification('DID_CHANGE_RENEWAL_STATUS', { subtype: 'AUTO_RENEW_ENABLED' }),
|
|
282
|
+
transaction: baseTransaction,
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
expect(sub.update).toHaveBeenCalledWith({ cancel_at_period_end: false });
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
describe('REVOKE / REFUND', () => {
|
|
290
|
+
it.each(['REVOKE', 'REFUND'] as AppStoreNotificationType[])(
|
|
291
|
+
'marks canceled + records cancelation_details + emits deleted (type=%s)',
|
|
292
|
+
async (type) => {
|
|
293
|
+
const sub = makeSubStub();
|
|
294
|
+
mockSubscriptionFindOne.mockResolvedValue(sub);
|
|
295
|
+
|
|
296
|
+
await handleAppStoreSubscriptionEvent({
|
|
297
|
+
notification: makeNotification(type),
|
|
298
|
+
transaction: baseTransaction,
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
expect(sub.update).toHaveBeenCalledWith(
|
|
302
|
+
expect.objectContaining({
|
|
303
|
+
status: 'canceled',
|
|
304
|
+
cancelation_details: expect.objectContaining({ reason: 'cancellation_requested' }),
|
|
305
|
+
})
|
|
306
|
+
);
|
|
307
|
+
expect(mockCreateEvent).toHaveBeenCalledWith('Subscription', 'customer.subscription.deleted', sub);
|
|
308
|
+
}
|
|
309
|
+
);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
describe('Informational types', () => {
|
|
313
|
+
it.each([
|
|
314
|
+
'PRICE_INCREASE',
|
|
315
|
+
'DID_CHANGE_RENEWAL_PREF',
|
|
316
|
+
'RENEWAL_EXTENDED',
|
|
317
|
+
'REFUND_DECLINED',
|
|
318
|
+
] as AppStoreNotificationType[])('records metadata without state change (type=%s)', async (type) => {
|
|
319
|
+
const sub = makeSubStub();
|
|
320
|
+
mockSubscriptionFindOne.mockResolvedValue(sub);
|
|
321
|
+
|
|
322
|
+
await handleAppStoreSubscriptionEvent({
|
|
323
|
+
notification: makeNotification(type),
|
|
324
|
+
transaction: baseTransaction,
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
expect(sub.update).toHaveBeenCalledWith(
|
|
328
|
+
expect.objectContaining({
|
|
329
|
+
metadata: expect.objectContaining({ app_store_last_notification_type: type }),
|
|
330
|
+
})
|
|
331
|
+
);
|
|
332
|
+
expect(mockCreateEvent).not.toHaveBeenCalled();
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
describe('Subscription not found', () => {
|
|
337
|
+
it('logs warn and no-ops for non-SUBSCRIBED notifications when local sub is missing', async () => {
|
|
338
|
+
mockSubscriptionFindOne.mockResolvedValue(null);
|
|
339
|
+
|
|
340
|
+
await handleAppStoreSubscriptionEvent({
|
|
341
|
+
notification: makeNotification('DID_RENEW'),
|
|
342
|
+
transaction: baseTransaction,
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
expect(mockSubscriptionCreate).not.toHaveBeenCalled();
|
|
346
|
+
expect(mockCreateEvent).not.toHaveBeenCalled();
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
describe('REFUND / REVOKE marker', () => {
|
|
351
|
+
it('REFUND mirrors to canceled and sets metadata.refunded', async () => {
|
|
352
|
+
const sub = makeSubStub();
|
|
353
|
+
mockSubscriptionFindOne.mockResolvedValue(sub);
|
|
354
|
+
|
|
355
|
+
await handleAppStoreSubscriptionEvent({
|
|
356
|
+
notification: makeNotification('REFUND'),
|
|
357
|
+
transaction: baseTransaction,
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
expect(sub.update).toHaveBeenCalledWith(
|
|
361
|
+
expect.objectContaining({
|
|
362
|
+
status: 'canceled',
|
|
363
|
+
metadata: expect.objectContaining({ refunded: true }),
|
|
364
|
+
})
|
|
365
|
+
);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('REVOKE mirrors to canceled but does NOT set metadata.refunded', async () => {
|
|
369
|
+
const sub = makeSubStub();
|
|
370
|
+
mockSubscriptionFindOne.mockResolvedValue(sub);
|
|
371
|
+
|
|
372
|
+
await handleAppStoreSubscriptionEvent({
|
|
373
|
+
notification: makeNotification('REVOKE'),
|
|
374
|
+
transaction: baseTransaction,
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
const patch = sub.update.mock.calls[0][0];
|
|
378
|
+
expect(patch.status).toBe('canceled');
|
|
379
|
+
expect(patch.metadata.refunded).toBeUndefined();
|
|
380
|
+
});
|
|
381
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Confirms Apple's real signature verification is wired up correctly —
|
|
2
|
+
// synthetic JWSes are rejected when APP_STORE_SKIP_SIGNATURE_VERIFY is NOT set.
|
|
3
|
+
//
|
|
4
|
+
// This file deliberately runs WITHOUT the skip env, so it must be the only
|
|
5
|
+
// real-mode test file. Other app-store spec files set the skip env at module
|
|
6
|
+
// top to be able to exercise control-flow with synthetic fixtures.
|
|
7
|
+
|
|
8
|
+
/* eslint-disable import/first */
|
|
9
|
+
// Ensure the env is *not* set, even if another test file in the same Jest
|
|
10
|
+
// worker happened to set it. We delete + reset module caches so a fresh
|
|
11
|
+
// verifier is constructed with the correct flag.
|
|
12
|
+
delete process.env.APP_STORE_SKIP_SIGNATURE_VERIFY;
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
verifySignedNotification,
|
|
16
|
+
verifySignedTransaction,
|
|
17
|
+
} from '../../../src/integrations/app-store/signed-data-verifier';
|
|
18
|
+
|
|
19
|
+
const makeSyntheticJws = (payload: object): string => {
|
|
20
|
+
const header = Buffer.from(JSON.stringify({ alg: 'ES256', x5c: ['fake-cert'] })).toString('base64url');
|
|
21
|
+
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
22
|
+
return `${header}.${body}.fake-signature`;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
describe('signed-data-verifier — real verification path', () => {
|
|
26
|
+
it('rejects synthetic transaction JWS (no valid x5c chain)', async () => {
|
|
27
|
+
const jws = makeSyntheticJws({
|
|
28
|
+
transactionId: 'txn_1',
|
|
29
|
+
originalTransactionId: 'orig_1',
|
|
30
|
+
productId: 'sub_06',
|
|
31
|
+
bundleId: 'com.example.app',
|
|
32
|
+
});
|
|
33
|
+
await expect(verifySignedTransaction(jws, 'com.example.app', 'sandbox')).rejects.toThrow();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('rejects synthetic notification JWS (no valid x5c chain)', async () => {
|
|
37
|
+
const jws = makeSyntheticJws({
|
|
38
|
+
notificationType: 'DID_RENEW',
|
|
39
|
+
notificationUUID: 'uuid-1',
|
|
40
|
+
data: { bundleId: 'com.example.app', environment: 'Sandbox' },
|
|
41
|
+
});
|
|
42
|
+
await expect(verifySignedNotification(jws, 'com.example.app', 'sandbox')).rejects.toThrow();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('throws when JWS format is structurally invalid', async () => {
|
|
46
|
+
await expect(verifySignedTransaction('not.a.jws', 'com.example.app', 'sandbox')).rejects.toThrow();
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('signed-data-verifier — skip mode', () => {
|
|
51
|
+
beforeAll(() => {
|
|
52
|
+
process.env.APP_STORE_SKIP_SIGNATURE_VERIFY = 'true';
|
|
53
|
+
});
|
|
54
|
+
afterAll(() => {
|
|
55
|
+
delete process.env.APP_STORE_SKIP_SIGNATURE_VERIFY;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('decodes synthetic transaction JWS when skip is enabled', async () => {
|
|
59
|
+
const jws = makeSyntheticJws({
|
|
60
|
+
transactionId: 'txn_skip',
|
|
61
|
+
originalTransactionId: 'orig_skip',
|
|
62
|
+
productId: 'sub_06',
|
|
63
|
+
bundleId: 'com.example.app',
|
|
64
|
+
});
|
|
65
|
+
const decoded = await verifySignedTransaction(jws, 'com.example.app', 'sandbox');
|
|
66
|
+
expect(decoded.transactionId).toBe('txn_skip');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('refuses malformed JWS even in skip mode (format check still runs)', async () => {
|
|
70
|
+
await expect(verifySignedTransaction('not.a.jws', 'com.example.app', 'sandbox')).rejects.toThrow();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Unit test for the ASSN webhook routing decode (PR #1381 review P1): we read
|
|
2
|
+
// bundleId/environment from the UNVERIFIED payload only to select the matching
|
|
3
|
+
// PaymentMethod before trusted verification.
|
|
4
|
+
|
|
5
|
+
import { peekNotificationRouting } from '../../../src/integrations/app-store/notification-routing';
|
|
6
|
+
|
|
7
|
+
function jwsWithPayload(obj: unknown): string {
|
|
8
|
+
const b64 = (s: string) => Buffer.from(s).toString('base64url');
|
|
9
|
+
return `${b64('{"alg":"ES256"}')}.${b64(JSON.stringify(obj))}.sig`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('peekNotificationRouting', () => {
|
|
13
|
+
it('extracts bundleId + environment from the JWS payload (no signature check)', () => {
|
|
14
|
+
const token = jwsWithPayload({ data: { bundleId: 'com.example.app', environment: 'Sandbox' } });
|
|
15
|
+
expect(peekNotificationRouting(token)).toEqual({ bundleId: 'com.example.app', environment: 'Sandbox' });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('returns undefined fields when data is absent', () => {
|
|
19
|
+
const token = jwsWithPayload({ notificationType: 'TEST' });
|
|
20
|
+
expect(peekNotificationRouting(token)).toEqual({ bundleId: undefined, environment: undefined });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('returns null for a malformed (non-JWS / non-JSON) payload', () => {
|
|
24
|
+
expect(peekNotificationRouting('not-a-jws')).toBeNull();
|
|
25
|
+
expect(peekNotificationRouting('a.@@@.c')).toBeNull();
|
|
26
|
+
});
|
|
27
|
+
});
|