@onesub/server 0.11.4 → 0.12.1
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/README.md +119 -2
- package/dist/__tests__/cache.test.d.ts +2 -0
- package/dist/__tests__/cache.test.d.ts.map +1 -0
- package/dist/__tests__/openapi.test.d.ts +2 -0
- package/dist/__tests__/openapi.test.d.ts.map +1 -0
- package/dist/__tests__/redis-store.test.d.ts +2 -0
- package/dist/__tests__/redis-store.test.d.ts.map +1 -0
- package/dist/__tests__/webhook-apple.test.d.ts +12 -0
- package/dist/__tests__/webhook-apple.test.d.ts.map +1 -0
- package/dist/__tests__/webhook-events.test.d.ts +2 -0
- package/dist/__tests__/webhook-events.test.d.ts.map +1 -0
- package/dist/__tests__/webhook-google.test.d.ts +13 -0
- package/dist/__tests__/webhook-google.test.d.ts.map +1 -0
- package/dist/__tests__/webhook-queue.test.d.ts +2 -0
- package/dist/__tests__/webhook-queue.test.d.ts.map +1 -0
- package/dist/cache.d.ts +32 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/index.cjs +3084 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +34 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3045 -118
- package/dist/index.js.map +1 -1
- package/dist/openapi.d.ts +41 -0
- package/dist/openapi.d.ts.map +1 -0
- package/dist/providers/apple.d.ts +1 -1
- package/dist/providers/apple.d.ts.map +1 -1
- package/dist/providers/google.d.ts.map +1 -1
- package/dist/routes/admin.d.ts +2 -1
- package/dist/routes/admin.d.ts.map +1 -1
- package/dist/routes/webhook-apple.d.ts +6 -0
- package/dist/routes/webhook-apple.d.ts.map +1 -0
- package/dist/routes/webhook-google.d.ts +7 -0
- package/dist/routes/webhook-google.d.ts.map +1 -0
- package/dist/routes/webhook.d.ts +6 -18
- package/dist/routes/webhook.d.ts.map +1 -1
- package/dist/stores/redis.d.ts +60 -0
- package/dist/stores/redis.d.ts.map +1 -0
- package/dist/stores/schema.d.ts.map +1 -1
- package/dist/tracing.d.ts +32 -0
- package/dist/tracing.d.ts.map +1 -0
- package/dist/webhook-events.d.ts +58 -0
- package/dist/webhook-events.d.ts.map +1 -0
- package/dist/webhook-queue.d.ts +92 -0
- package/dist/webhook-queue.d.ts.map +1 -0
- package/package.json +33 -4
- package/sql/schema.sql +10 -0
- package/dist/__tests__/admin-customer-detail.test.js +0 -146
- package/dist/__tests__/admin-customer-detail.test.js.map +0 -1
- package/dist/__tests__/admin-list-subscriptions.test.js +0 -192
- package/dist/__tests__/admin-list-subscriptions.test.js.map +0 -1
- package/dist/__tests__/admin-subscription-detail.test.js +0 -112
- package/dist/__tests__/admin-subscription-detail.test.js.map +0 -1
- package/dist/__tests__/apple-root-ca.test.js +0 -41
- package/dist/__tests__/apple-root-ca.test.js.map +0 -1
- package/dist/__tests__/apple-status-api.test.js +0 -345
- package/dist/__tests__/apple-status-api.test.js.map +0 -1
- package/dist/__tests__/entitlements.test.js +0 -268
- package/dist/__tests__/entitlements.test.js.map +0 -1
- package/dist/__tests__/error-codes.test.js +0 -168
- package/dist/__tests__/error-codes.test.js.map +0 -1
- package/dist/__tests__/fetch-hardening.test.js +0 -192
- package/dist/__tests__/fetch-hardening.test.js.map +0 -1
- package/dist/__tests__/google-paused.test.js +0 -126
- package/dist/__tests__/google-paused.test.js.map +0 -1
- package/dist/__tests__/google-price-change.test.js +0 -186
- package/dist/__tests__/google-price-change.test.js.map +0 -1
- package/dist/__tests__/google-subscriptions-v2.test.js +0 -206
- package/dist/__tests__/google-subscriptions-v2.test.js.map +0 -1
- package/dist/__tests__/lifecycle-scenarios.test.js +0 -579
- package/dist/__tests__/lifecycle-scenarios.test.js.map +0 -1
- package/dist/__tests__/lifecycle-states.test.js +0 -423
- package/dist/__tests__/lifecycle-states.test.js.map +0 -1
- package/dist/__tests__/linked-purchase-token.test.js +0 -227
- package/dist/__tests__/linked-purchase-token.test.js.map +0 -1
- package/dist/__tests__/metrics.test.js +0 -340
- package/dist/__tests__/metrics.test.js.map +0 -1
- package/dist/__tests__/mock-provider.test.js +0 -188
- package/dist/__tests__/mock-provider.test.js.map +0 -1
- package/dist/__tests__/paused-auto-resume.test.js +0 -162
- package/dist/__tests__/paused-auto-resume.test.js.map +0 -1
- package/dist/__tests__/providers.test.js +0 -321
- package/dist/__tests__/providers.test.js.map +0 -1
- package/dist/__tests__/refund-policy.test.js +0 -317
- package/dist/__tests__/refund-policy.test.js.map +0 -1
- package/dist/__tests__/schema.test.js +0 -46
- package/dist/__tests__/schema.test.js.map +0 -1
- package/dist/__tests__/store.test.js +0 -172
- package/dist/__tests__/store.test.js.map +0 -1
- package/dist/__tests__/test-utils.js +0 -30
- package/dist/__tests__/test-utils.js.map +0 -1
- package/dist/__tests__/webhook-refund.test.js +0 -313
- package/dist/__tests__/webhook-refund.test.js.map +0 -1
- package/dist/errors.js +0 -19
- package/dist/errors.js.map +0 -1
- package/dist/http.js +0 -36
- package/dist/http.js.map +0 -1
- package/dist/logger.js +0 -20
- package/dist/logger.js.map +0 -1
- package/dist/providers/apple-root-ca.js +0 -38
- package/dist/providers/apple-root-ca.js.map +0 -1
- package/dist/providers/apple.js +0 -499
- package/dist/providers/apple.js.map +0 -1
- package/dist/providers/google.js +0 -493
- package/dist/providers/google.js.map +0 -1
- package/dist/providers/mock.js +0 -93
- package/dist/providers/mock.js.map +0 -1
- package/dist/routes/admin.js +0 -251
- package/dist/routes/admin.js.map +0 -1
- package/dist/routes/entitlements.js +0 -130
- package/dist/routes/entitlements.js.map +0 -1
- package/dist/routes/metrics.js +0 -281
- package/dist/routes/metrics.js.map +0 -1
- package/dist/routes/purchase.js +0 -187
- package/dist/routes/purchase.js.map +0 -1
- package/dist/routes/status.js +0 -55
- package/dist/routes/status.js.map +0 -1
- package/dist/routes/validate.js +0 -70
- package/dist/routes/validate.js.map +0 -1
- package/dist/routes/webhook.js +0 -441
- package/dist/routes/webhook.js.map +0 -1
- package/dist/store.js +0 -145
- package/dist/store.js.map +0 -1
- package/dist/stores/postgres.js +0 -329
- package/dist/stores/postgres.js.map +0 -1
- package/dist/stores/schema.js +0 -57
- package/dist/stores/schema.js.map +0 -1
|
@@ -1,345 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for fetchAppleSubscriptionStatus + the Apple webhook unknown-tx fallback.
|
|
3
|
-
*
|
|
4
|
-
* Covers:
|
|
5
|
-
* - status code 1..5 → SubscriptionStatus mapping
|
|
6
|
-
* - sandbox vs production host routing
|
|
7
|
-
* - missing API credentials → returns null (no throw)
|
|
8
|
-
* - HTTP error / empty data → returns null
|
|
9
|
-
* - webhook receives notification for unknown originalTransactionId →
|
|
10
|
-
* calls Status API + saves the returned record
|
|
11
|
-
* - webhook unknown tx without API creds → no fetch, just logs
|
|
12
|
-
*/
|
|
13
|
-
import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest';
|
|
14
|
-
import express from 'express';
|
|
15
|
-
import { generateKeyPairSync } from 'crypto';
|
|
16
|
-
import { SUBSCRIPTION_STATUS } from '@onesub/shared';
|
|
17
|
-
import { fetchAppleSubscriptionStatus } from '../providers/apple.js';
|
|
18
|
-
import { createWebhookRouter } from '../routes/webhook.js';
|
|
19
|
-
import { InMemorySubscriptionStore, InMemoryPurchaseStore } from '../store.js';
|
|
20
|
-
import { isLocalhostUrl } from './test-utils.js';
|
|
21
|
-
// ── helpers ─────────────────────────────────────────────────────────────────
|
|
22
|
-
function makeJws(payload) {
|
|
23
|
-
const header = Buffer.from(JSON.stringify({ alg: 'ES256' })).toString('base64url');
|
|
24
|
-
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
25
|
-
return `${header}.${body}.fakesig`;
|
|
26
|
-
}
|
|
27
|
-
let testEcKey;
|
|
28
|
-
beforeAll(() => {
|
|
29
|
-
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
|
30
|
-
testEcKey = privateKey.export({ type: 'pkcs8', format: 'pem' });
|
|
31
|
-
});
|
|
32
|
-
beforeEach(() => {
|
|
33
|
-
vi.restoreAllMocks();
|
|
34
|
-
});
|
|
35
|
-
function appleConfigWithApi(overrides) {
|
|
36
|
-
return {
|
|
37
|
-
bundleId: 'com.example.app',
|
|
38
|
-
skipJwsVerification: true,
|
|
39
|
-
keyId: 'TESTKEY1',
|
|
40
|
-
issuerId: '12345678-1234-1234-1234-123456789012',
|
|
41
|
-
privateKey: testEcKey,
|
|
42
|
-
...overrides,
|
|
43
|
-
};
|
|
44
|
-
}
|
|
45
|
-
/**
|
|
46
|
-
* Mock global fetch with localhost pass-through. Apple Status API requests
|
|
47
|
-
* resolve to the provided body; localhost (our test http server) is forwarded
|
|
48
|
-
* to the real fetch implementation.
|
|
49
|
-
*/
|
|
50
|
-
function mockAppleStatusFetch(responseBody, opts) {
|
|
51
|
-
const originalFetch = global.fetch;
|
|
52
|
-
const calls = [];
|
|
53
|
-
vi.spyOn(global, 'fetch').mockImplementation(async (url, init) => {
|
|
54
|
-
if (isLocalhostUrl(url)) {
|
|
55
|
-
return originalFetch(url, init);
|
|
56
|
-
}
|
|
57
|
-
const urlStr = String(url);
|
|
58
|
-
calls.push({
|
|
59
|
-
url: urlStr,
|
|
60
|
-
method: init?.method,
|
|
61
|
-
headers: init?.headers,
|
|
62
|
-
});
|
|
63
|
-
return {
|
|
64
|
-
ok: (opts?.status ?? 200) < 400,
|
|
65
|
-
status: opts?.status ?? 200,
|
|
66
|
-
json: async () => responseBody,
|
|
67
|
-
text: async () => JSON.stringify(responseBody),
|
|
68
|
-
};
|
|
69
|
-
});
|
|
70
|
-
return calls;
|
|
71
|
-
}
|
|
72
|
-
function makeStatusResponse(opts) {
|
|
73
|
-
const tx = makeJws({
|
|
74
|
-
bundleId: 'com.example.app',
|
|
75
|
-
type: 'Auto-Renewable Subscription',
|
|
76
|
-
productId: opts.productId ?? 'pro_monthly',
|
|
77
|
-
transactionId: 'tx_inner',
|
|
78
|
-
originalTransactionId: opts.originalTransactionId,
|
|
79
|
-
purchaseDate: Date.now() - 30 * 86400000,
|
|
80
|
-
originalPurchaseDate: Date.now() - 30 * 86400000,
|
|
81
|
-
expiresDate: opts.expiresDate ?? Date.now() + 86400000,
|
|
82
|
-
});
|
|
83
|
-
const renewal = makeJws({ autoRenewStatus: opts.autoRenewStatus ?? 1 });
|
|
84
|
-
return {
|
|
85
|
-
bundleId: 'com.example.app',
|
|
86
|
-
environment: 'Production',
|
|
87
|
-
data: [{
|
|
88
|
-
subscriptionGroupIdentifier: 'group_a',
|
|
89
|
-
lastTransactions: [{
|
|
90
|
-
originalTransactionId: opts.originalTransactionId,
|
|
91
|
-
status: opts.status,
|
|
92
|
-
signedTransactionInfo: tx,
|
|
93
|
-
signedRenewalInfo: renewal,
|
|
94
|
-
}],
|
|
95
|
-
}],
|
|
96
|
-
};
|
|
97
|
-
}
|
|
98
|
-
// ── fetchAppleSubscriptionStatus unit tests ─────────────────────────────────
|
|
99
|
-
describe('fetchAppleSubscriptionStatus', () => {
|
|
100
|
-
it('returns null when API credentials are missing', async () => {
|
|
101
|
-
const fetchSpy = vi.spyOn(global, 'fetch');
|
|
102
|
-
const result = await fetchAppleSubscriptionStatus('orig_xyz', {
|
|
103
|
-
bundleId: 'com.example.app',
|
|
104
|
-
skipJwsVerification: true,
|
|
105
|
-
});
|
|
106
|
-
expect(result).toBeNull();
|
|
107
|
-
expect(fetchSpy).not.toHaveBeenCalled();
|
|
108
|
-
});
|
|
109
|
-
it('returns null when mockMode is enabled', async () => {
|
|
110
|
-
const fetchSpy = vi.spyOn(global, 'fetch');
|
|
111
|
-
const result = await fetchAppleSubscriptionStatus('orig_xyz', appleConfigWithApi({ mockMode: true }));
|
|
112
|
-
expect(result).toBeNull();
|
|
113
|
-
expect(fetchSpy).not.toHaveBeenCalled();
|
|
114
|
-
});
|
|
115
|
-
it('GET .../inApps/v1/subscriptions/<id> with Bearer JWT', async () => {
|
|
116
|
-
const calls = mockAppleStatusFetch(makeStatusResponse({
|
|
117
|
-
originalTransactionId: 'orig_active',
|
|
118
|
-
status: 1,
|
|
119
|
-
}));
|
|
120
|
-
await fetchAppleSubscriptionStatus('orig_active', appleConfigWithApi());
|
|
121
|
-
expect(calls).toHaveLength(1);
|
|
122
|
-
expect(calls[0].url).toContain('api.storekit.itunes.apple.com');
|
|
123
|
-
expect(calls[0].url).toContain('/inApps/v1/subscriptions/orig_active');
|
|
124
|
-
expect(calls[0].headers?.Authorization).toMatch(/^Bearer /);
|
|
125
|
-
});
|
|
126
|
-
it('routes to sandbox host when options.sandbox is true', async () => {
|
|
127
|
-
const calls = mockAppleStatusFetch(makeStatusResponse({
|
|
128
|
-
originalTransactionId: 'orig_sb',
|
|
129
|
-
status: 1,
|
|
130
|
-
}));
|
|
131
|
-
await fetchAppleSubscriptionStatus('orig_sb', appleConfigWithApi(), { sandbox: true });
|
|
132
|
-
expect(calls[0].url).toContain('api.storekit-sandbox.itunes.apple.com');
|
|
133
|
-
});
|
|
134
|
-
it('maps status code 1 → active', async () => {
|
|
135
|
-
mockAppleStatusFetch(makeStatusResponse({ originalTransactionId: 'o1', status: 1 }));
|
|
136
|
-
const result = await fetchAppleSubscriptionStatus('o1', appleConfigWithApi());
|
|
137
|
-
expect(result?.status).toBe(SUBSCRIPTION_STATUS.ACTIVE);
|
|
138
|
-
});
|
|
139
|
-
it('maps status code 2 → expired', async () => {
|
|
140
|
-
mockAppleStatusFetch(makeStatusResponse({ originalTransactionId: 'o2', status: 2 }));
|
|
141
|
-
const result = await fetchAppleSubscriptionStatus('o2', appleConfigWithApi());
|
|
142
|
-
expect(result?.status).toBe(SUBSCRIPTION_STATUS.EXPIRED);
|
|
143
|
-
});
|
|
144
|
-
it('maps status code 3 → on_hold (billing retry)', async () => {
|
|
145
|
-
mockAppleStatusFetch(makeStatusResponse({ originalTransactionId: 'o3', status: 3 }));
|
|
146
|
-
const result = await fetchAppleSubscriptionStatus('o3', appleConfigWithApi());
|
|
147
|
-
expect(result?.status).toBe(SUBSCRIPTION_STATUS.ON_HOLD);
|
|
148
|
-
});
|
|
149
|
-
it('maps status code 4 → grace_period', async () => {
|
|
150
|
-
mockAppleStatusFetch(makeStatusResponse({ originalTransactionId: 'o4', status: 4 }));
|
|
151
|
-
const result = await fetchAppleSubscriptionStatus('o4', appleConfigWithApi());
|
|
152
|
-
expect(result?.status).toBe(SUBSCRIPTION_STATUS.GRACE_PERIOD);
|
|
153
|
-
});
|
|
154
|
-
it('maps status code 5 → canceled (revoked)', async () => {
|
|
155
|
-
mockAppleStatusFetch(makeStatusResponse({ originalTransactionId: 'o5', status: 5 }));
|
|
156
|
-
const result = await fetchAppleSubscriptionStatus('o5', appleConfigWithApi());
|
|
157
|
-
expect(result?.status).toBe(SUBSCRIPTION_STATUS.CANCELED);
|
|
158
|
-
});
|
|
159
|
-
it('reads autoRenewStatus from signedRenewalInfo for willRenew', async () => {
|
|
160
|
-
mockAppleStatusFetch(makeStatusResponse({
|
|
161
|
-
originalTransactionId: 'o_renew',
|
|
162
|
-
status: 1,
|
|
163
|
-
autoRenewStatus: 0,
|
|
164
|
-
}));
|
|
165
|
-
const result = await fetchAppleSubscriptionStatus('o_renew', appleConfigWithApi());
|
|
166
|
-
expect(result?.willRenew).toBe(false);
|
|
167
|
-
});
|
|
168
|
-
it('returns null on HTTP 404', async () => {
|
|
169
|
-
mockAppleStatusFetch({ errorCode: 4040010 }, { status: 404 });
|
|
170
|
-
const result = await fetchAppleSubscriptionStatus('not_found', appleConfigWithApi());
|
|
171
|
-
expect(result).toBeNull();
|
|
172
|
-
});
|
|
173
|
-
it('returns null when data array is empty', async () => {
|
|
174
|
-
mockAppleStatusFetch({ bundleId: 'com.example.app', environment: 'Production', data: [] });
|
|
175
|
-
const result = await fetchAppleSubscriptionStatus('orig_empty', appleConfigWithApi());
|
|
176
|
-
expect(result).toBeNull();
|
|
177
|
-
});
|
|
178
|
-
it('returns null when matching originalTransactionId not in lastTransactions', async () => {
|
|
179
|
-
mockAppleStatusFetch({
|
|
180
|
-
bundleId: 'com.example.app',
|
|
181
|
-
environment: 'Production',
|
|
182
|
-
data: [{
|
|
183
|
-
subscriptionGroupIdentifier: 'g',
|
|
184
|
-
lastTransactions: [{
|
|
185
|
-
originalTransactionId: 'different_id',
|
|
186
|
-
status: 1,
|
|
187
|
-
signedTransactionInfo: makeJws({ productId: 'p', expiresDate: Date.now() }),
|
|
188
|
-
}],
|
|
189
|
-
}],
|
|
190
|
-
});
|
|
191
|
-
const result = await fetchAppleSubscriptionStatus('expected_id', appleConfigWithApi());
|
|
192
|
-
expect(result).toBeNull();
|
|
193
|
-
});
|
|
194
|
-
it('preserves originalTransactionId from caller (not from inner JWS)', async () => {
|
|
195
|
-
mockAppleStatusFetch(makeStatusResponse({
|
|
196
|
-
originalTransactionId: 'orig_caller',
|
|
197
|
-
status: 1,
|
|
198
|
-
}));
|
|
199
|
-
const result = await fetchAppleSubscriptionStatus('orig_caller', appleConfigWithApi());
|
|
200
|
-
expect(result?.originalTransactionId).toBe('orig_caller');
|
|
201
|
-
});
|
|
202
|
-
});
|
|
203
|
-
function buildAppleWebhookServer(config) {
|
|
204
|
-
const store = new InMemorySubscriptionStore();
|
|
205
|
-
const purchaseStore = new InMemoryPurchaseStore();
|
|
206
|
-
const app = express();
|
|
207
|
-
app.use(express.json());
|
|
208
|
-
app.use(createWebhookRouter(config, store, purchaseStore));
|
|
209
|
-
return {
|
|
210
|
-
store,
|
|
211
|
-
server: {
|
|
212
|
-
async request(path, body) {
|
|
213
|
-
const httpServer = app.listen(0);
|
|
214
|
-
const address = httpServer.address();
|
|
215
|
-
const port = typeof address === 'object' && address ? address.port : 0;
|
|
216
|
-
try {
|
|
217
|
-
const resp = await fetch(`http://127.0.0.1:${port}${path}`, {
|
|
218
|
-
method: 'POST',
|
|
219
|
-
headers: { 'Content-Type': 'application/json' },
|
|
220
|
-
body: JSON.stringify(body),
|
|
221
|
-
});
|
|
222
|
-
const text = await resp.text();
|
|
223
|
-
let parsed = text;
|
|
224
|
-
try {
|
|
225
|
-
parsed = JSON.parse(text);
|
|
226
|
-
}
|
|
227
|
-
catch { /* keep as text */ }
|
|
228
|
-
return { status: resp.status, body: parsed };
|
|
229
|
-
}
|
|
230
|
-
finally {
|
|
231
|
-
await new Promise((resolve) => httpServer.close(() => resolve()));
|
|
232
|
-
}
|
|
233
|
-
},
|
|
234
|
-
},
|
|
235
|
-
};
|
|
236
|
-
}
|
|
237
|
-
function buildUnknownTxNotification(originalTransactionId, opts) {
|
|
238
|
-
const signedTransactionInfo = makeJws({
|
|
239
|
-
bundleId: 'com.example.app',
|
|
240
|
-
type: 'Auto-Renewable Subscription',
|
|
241
|
-
productId: 'pro_monthly',
|
|
242
|
-
transactionId: 'tx_notify',
|
|
243
|
-
originalTransactionId,
|
|
244
|
-
purchaseDate: Date.now(),
|
|
245
|
-
expiresDate: Date.now() + 86400000,
|
|
246
|
-
environment: opts?.sandbox ? 'Sandbox' : 'Production',
|
|
247
|
-
});
|
|
248
|
-
return {
|
|
249
|
-
signedPayload: makeJws({
|
|
250
|
-
notificationType: 'DID_RENEW',
|
|
251
|
-
data: {
|
|
252
|
-
signedTransactionInfo,
|
|
253
|
-
signedRenewalInfo: makeJws({ autoRenewStatus: 1 }),
|
|
254
|
-
},
|
|
255
|
-
}),
|
|
256
|
-
};
|
|
257
|
-
}
|
|
258
|
-
describe('Apple webhook — unknown originalTransactionId fallback', () => {
|
|
259
|
-
it('fetches from Status API and saves a placeholder record when API creds present', async () => {
|
|
260
|
-
mockAppleStatusFetch(makeStatusResponse({
|
|
261
|
-
originalTransactionId: 'orig_unknown',
|
|
262
|
-
status: 1,
|
|
263
|
-
}));
|
|
264
|
-
const config = {
|
|
265
|
-
apple: appleConfigWithApi(),
|
|
266
|
-
database: { url: '' },
|
|
267
|
-
};
|
|
268
|
-
const { store, server } = buildAppleWebhookServer(config);
|
|
269
|
-
const resp = await server.request('/onesub/webhook/apple', buildUnknownTxNotification('orig_unknown'));
|
|
270
|
-
expect(resp.status).toBe(200);
|
|
271
|
-
const saved = await store.getByTransactionId('orig_unknown');
|
|
272
|
-
expect(saved).not.toBeNull();
|
|
273
|
-
expect(saved?.status).toBe(SUBSCRIPTION_STATUS.ACTIVE);
|
|
274
|
-
// userId is unknown at webhook time — placeholder is the originalTransactionId
|
|
275
|
-
expect(saved?.userId).toBe('orig_unknown');
|
|
276
|
-
});
|
|
277
|
-
it('routes to sandbox host when notification environment is Sandbox', async () => {
|
|
278
|
-
const calls = mockAppleStatusFetch(makeStatusResponse({
|
|
279
|
-
originalTransactionId: 'orig_sb_unknown',
|
|
280
|
-
status: 1,
|
|
281
|
-
}));
|
|
282
|
-
const config = {
|
|
283
|
-
apple: appleConfigWithApi(),
|
|
284
|
-
database: { url: '' },
|
|
285
|
-
};
|
|
286
|
-
const { server } = buildAppleWebhookServer(config);
|
|
287
|
-
await server.request('/onesub/webhook/apple', buildUnknownTxNotification('orig_sb_unknown', { sandbox: true }));
|
|
288
|
-
const apiCall = calls.find((c) => c.url.includes('/inApps/v1/subscriptions/'));
|
|
289
|
-
expect(apiCall?.url).toContain('api.storekit-sandbox.itunes.apple.com');
|
|
290
|
-
});
|
|
291
|
-
it('does not call Status API when issuerId/keyId/privateKey are missing', async () => {
|
|
292
|
-
const originalFetch = global.fetch;
|
|
293
|
-
const outboundCalls = [];
|
|
294
|
-
vi.spyOn(global, 'fetch').mockImplementation(async (url, init) => {
|
|
295
|
-
if (isLocalhostUrl(url)) {
|
|
296
|
-
return originalFetch(url, init);
|
|
297
|
-
}
|
|
298
|
-
outboundCalls.push({ url: String(url) });
|
|
299
|
-
return { ok: true, status: 200, json: async () => ({}), text: async () => '' };
|
|
300
|
-
});
|
|
301
|
-
const config = {
|
|
302
|
-
apple: { bundleId: 'com.example.app', skipJwsVerification: true }, // no API creds
|
|
303
|
-
database: { url: '' },
|
|
304
|
-
};
|
|
305
|
-
const { store, server } = buildAppleWebhookServer(config);
|
|
306
|
-
const resp = await server.request('/onesub/webhook/apple', buildUnknownTxNotification('orig_no_creds'));
|
|
307
|
-
expect(resp.status).toBe(200);
|
|
308
|
-
expect(await store.getByTransactionId('orig_no_creds')).toBeNull();
|
|
309
|
-
expect(outboundCalls).toHaveLength(0);
|
|
310
|
-
});
|
|
311
|
-
it('does not overwrite an existing record (still the normal update path)', async () => {
|
|
312
|
-
const originalFetch = global.fetch;
|
|
313
|
-
const outboundCalls = [];
|
|
314
|
-
vi.spyOn(global, 'fetch').mockImplementation(async (url, init) => {
|
|
315
|
-
if (isLocalhostUrl(url)) {
|
|
316
|
-
return originalFetch(url, init);
|
|
317
|
-
}
|
|
318
|
-
outboundCalls.push({ url: String(url) });
|
|
319
|
-
return { ok: true, status: 200, json: async () => ({}), text: async () => '' };
|
|
320
|
-
});
|
|
321
|
-
const config = {
|
|
322
|
-
apple: appleConfigWithApi(),
|
|
323
|
-
database: { url: '' },
|
|
324
|
-
};
|
|
325
|
-
const { store, server } = buildAppleWebhookServer(config);
|
|
326
|
-
const existing = {
|
|
327
|
-
userId: 'real_user',
|
|
328
|
-
productId: 'pro_monthly',
|
|
329
|
-
platform: 'apple',
|
|
330
|
-
status: SUBSCRIPTION_STATUS.ACTIVE,
|
|
331
|
-
expiresAt: '2027-01-01T00:00:00.000Z',
|
|
332
|
-
originalTransactionId: 'orig_known',
|
|
333
|
-
purchasedAt: '2026-01-01T00:00:00.000Z',
|
|
334
|
-
willRenew: true,
|
|
335
|
-
};
|
|
336
|
-
await store.save(existing);
|
|
337
|
-
const resp = await server.request('/onesub/webhook/apple', buildUnknownTxNotification('orig_known'));
|
|
338
|
-
expect(resp.status).toBe(200);
|
|
339
|
-
// Existing record's userId preserved (not replaced with placeholder)
|
|
340
|
-
expect((await store.getByTransactionId('orig_known'))?.userId).toBe('real_user');
|
|
341
|
-
// No outbound Status API call since we already had the record
|
|
342
|
-
expect(outboundCalls.find((c) => c.url.includes('/inApps/v1/subscriptions/'))).toBeUndefined();
|
|
343
|
-
});
|
|
344
|
-
});
|
|
345
|
-
//# sourceMappingURL=apple-status-api.test.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"apple-status-api.test.js","sourceRoot":"","sources":["../../src/__tests__/apple-status-api.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,268 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Entitlements abstraction tests.
|
|
3
|
-
*
|
|
4
|
-
* Covers:
|
|
5
|
-
* - evaluateEntitlement helper (subscription / purchase / mixed / none /
|
|
6
|
-
* expired sub / wrong status / consumable excluded)
|
|
7
|
-
* - GET /onesub/entitlement single-check route (404 unknown id, 400 bad input)
|
|
8
|
-
* - GET /onesub/entitlements bulk route
|
|
9
|
-
* - Router not mounted when config.entitlements is absent (404)
|
|
10
|
-
* - getAllByUserId returns multi-product subscriptions (regression for the
|
|
11
|
-
* InMemoryStore change that backs entitlements)
|
|
12
|
-
*/
|
|
13
|
-
import { describe, it, expect, beforeEach } from 'vitest';
|
|
14
|
-
import express from 'express';
|
|
15
|
-
import { SUBSCRIPTION_STATUS, PURCHASE_TYPE } from '@onesub/shared';
|
|
16
|
-
import { createOneSubMiddleware } from '../index.js';
|
|
17
|
-
import { evaluateEntitlement } from '../routes/entitlements.js';
|
|
18
|
-
import { InMemorySubscriptionStore, InMemoryPurchaseStore } from '../store.js';
|
|
19
|
-
// ── helpers ─────────────────────────────────────────────────────────────────
|
|
20
|
-
const futureExpiry = '2099-01-01T00:00:00.000Z';
|
|
21
|
-
const pastExpiry = '2024-01-01T00:00:00.000Z';
|
|
22
|
-
function sub(overrides) {
|
|
23
|
-
return {
|
|
24
|
-
userId: 'u',
|
|
25
|
-
productId: 'pro_monthly',
|
|
26
|
-
platform: 'apple',
|
|
27
|
-
status: 'active',
|
|
28
|
-
expiresAt: futureExpiry,
|
|
29
|
-
originalTransactionId: `orig_${Math.random()}`,
|
|
30
|
-
purchasedAt: '2026-01-01T00:00:00.000Z',
|
|
31
|
-
willRenew: true,
|
|
32
|
-
...overrides,
|
|
33
|
-
};
|
|
34
|
-
}
|
|
35
|
-
function purchase(overrides) {
|
|
36
|
-
return {
|
|
37
|
-
userId: 'u',
|
|
38
|
-
productId: 'lifetime_pass',
|
|
39
|
-
platform: 'apple',
|
|
40
|
-
type: 'non_consumable',
|
|
41
|
-
transactionId: `tx_${Math.random()}`,
|
|
42
|
-
purchasedAt: '2026-01-01T00:00:00.000Z',
|
|
43
|
-
quantity: 1,
|
|
44
|
-
...overrides,
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
function spinUp(handler) {
|
|
48
|
-
return {
|
|
49
|
-
async get(path) {
|
|
50
|
-
const httpServer = handler.listen(0);
|
|
51
|
-
const port = httpServer.address().port;
|
|
52
|
-
try {
|
|
53
|
-
const resp = await fetch(`http://127.0.0.1:${port}${path}`);
|
|
54
|
-
const text = await resp.text();
|
|
55
|
-
let parsed = text;
|
|
56
|
-
try {
|
|
57
|
-
parsed = JSON.parse(text);
|
|
58
|
-
}
|
|
59
|
-
catch { /* keep as text */ }
|
|
60
|
-
return { status: resp.status, body: parsed };
|
|
61
|
-
}
|
|
62
|
-
finally {
|
|
63
|
-
await new Promise((r) => httpServer.close(() => r()));
|
|
64
|
-
}
|
|
65
|
-
},
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
function buildServer(opts) {
|
|
69
|
-
const store = new InMemorySubscriptionStore();
|
|
70
|
-
const purchaseStore = new InMemoryPurchaseStore();
|
|
71
|
-
const config = {
|
|
72
|
-
apple: { bundleId: 'com.example.app', skipJwsVerification: true },
|
|
73
|
-
database: { url: '' },
|
|
74
|
-
entitlements: opts.entitlements,
|
|
75
|
-
};
|
|
76
|
-
const app = express();
|
|
77
|
-
// createOneSubMiddleware mounts express.json itself, but the entitlement
|
|
78
|
-
// routes are GET-only so it's fine either way.
|
|
79
|
-
const middleware = createOneSubMiddleware({ ...config, store, purchaseStore });
|
|
80
|
-
app.use(middleware);
|
|
81
|
-
return { store, purchaseStore, server: spinUp(app) };
|
|
82
|
-
}
|
|
83
|
-
const PREMIUM = {
|
|
84
|
-
premium: { productIds: ['pro_monthly', 'pro_yearly', 'lifetime_pass'] },
|
|
85
|
-
};
|
|
86
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
87
|
-
// evaluateEntitlement (unit, no HTTP)
|
|
88
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
89
|
-
describe('evaluateEntitlement', () => {
|
|
90
|
-
let store;
|
|
91
|
-
let purchaseStore;
|
|
92
|
-
beforeEach(() => {
|
|
93
|
-
store = new InMemorySubscriptionStore();
|
|
94
|
-
purchaseStore = new InMemoryPurchaseStore();
|
|
95
|
-
});
|
|
96
|
-
it('active subscription matching one of the productIds → entitled', async () => {
|
|
97
|
-
await store.save(sub({ userId: 'u1', productId: 'pro_yearly' }));
|
|
98
|
-
const result = await evaluateEntitlement('u1', PREMIUM.premium, store, purchaseStore);
|
|
99
|
-
expect(result.active).toBe(true);
|
|
100
|
-
expect(result.source).toBe('subscription');
|
|
101
|
-
expect(result.productId).toBe('pro_yearly');
|
|
102
|
-
expect(result.expiresAt).toBe(futureExpiry);
|
|
103
|
-
});
|
|
104
|
-
it('non-consumable purchase matching one of the productIds → entitled', async () => {
|
|
105
|
-
await purchaseStore.savePurchase(purchase({ userId: 'u2', productId: 'lifetime_pass' }));
|
|
106
|
-
const result = await evaluateEntitlement('u2', PREMIUM.premium, store, purchaseStore);
|
|
107
|
-
expect(result.active).toBe(true);
|
|
108
|
-
expect(result.source).toBe('purchase');
|
|
109
|
-
expect(result.productId).toBe('lifetime_pass');
|
|
110
|
-
expect(result.expiresAt).toBeUndefined();
|
|
111
|
-
});
|
|
112
|
-
it('no matching record → not entitled', async () => {
|
|
113
|
-
const result = await evaluateEntitlement('u_none', PREMIUM.premium, store, purchaseStore);
|
|
114
|
-
expect(result).toEqual({ active: false, source: null });
|
|
115
|
-
});
|
|
116
|
-
it('subscription preferred over purchase when both match', async () => {
|
|
117
|
-
await store.save(sub({ userId: 'u3', productId: 'pro_monthly' }));
|
|
118
|
-
await purchaseStore.savePurchase(purchase({ userId: 'u3', productId: 'lifetime_pass' }));
|
|
119
|
-
const result = await evaluateEntitlement('u3', PREMIUM.premium, store, purchaseStore);
|
|
120
|
-
expect(result.source).toBe('subscription');
|
|
121
|
-
expect(result.productId).toBe('pro_monthly');
|
|
122
|
-
});
|
|
123
|
-
it('expired subscription (expiresAt < now) → not entitled even if status=active', async () => {
|
|
124
|
-
await store.save(sub({ userId: 'u4', productId: 'pro_monthly', expiresAt: pastExpiry }));
|
|
125
|
-
const result = await evaluateEntitlement('u4', PREMIUM.premium, store, purchaseStore);
|
|
126
|
-
expect(result.active).toBe(false);
|
|
127
|
-
});
|
|
128
|
-
it('grace_period subscription → entitled (still has valid expiresAt)', async () => {
|
|
129
|
-
await store.save(sub({ userId: 'u5', productId: 'pro_monthly', status: 'grace_period' }));
|
|
130
|
-
const result = await evaluateEntitlement('u5', PREMIUM.premium, store, purchaseStore);
|
|
131
|
-
expect(result.active).toBe(true);
|
|
132
|
-
expect(result.source).toBe('subscription');
|
|
133
|
-
});
|
|
134
|
-
it('on_hold / paused / canceled / expired status → not entitled', async () => {
|
|
135
|
-
for (const status of ['on_hold', 'paused', 'canceled', 'expired']) {
|
|
136
|
-
const localStore = new InMemorySubscriptionStore();
|
|
137
|
-
await localStore.save(sub({ userId: 'u', productId: 'pro_monthly', status }));
|
|
138
|
-
const result = await evaluateEntitlement('u', PREMIUM.premium, localStore, purchaseStore);
|
|
139
|
-
expect(result.active).toBe(false);
|
|
140
|
-
}
|
|
141
|
-
});
|
|
142
|
-
it('consumable purchase is NOT considered for entitlement', async () => {
|
|
143
|
-
await purchaseStore.savePurchase(purchase({
|
|
144
|
-
userId: 'u6',
|
|
145
|
-
productId: 'pro_monthly', // even matching productId
|
|
146
|
-
type: 'consumable',
|
|
147
|
-
}));
|
|
148
|
-
const result = await evaluateEntitlement('u6', PREMIUM.premium, store, purchaseStore);
|
|
149
|
-
expect(result.active).toBe(false);
|
|
150
|
-
});
|
|
151
|
-
it('multi-product subscriptions: returns the first matching active sub', async () => {
|
|
152
|
-
// User has two subs — one for pro_yearly (matches), one for off-premium product (no match)
|
|
153
|
-
await store.save(sub({ userId: 'u7', productId: 'unrelated_product' }));
|
|
154
|
-
await store.save(sub({ userId: 'u7', productId: 'pro_yearly' }));
|
|
155
|
-
const result = await evaluateEntitlement('u7', PREMIUM.premium, store, purchaseStore);
|
|
156
|
-
expect(result.active).toBe(true);
|
|
157
|
-
expect(result.productId).toBe('pro_yearly');
|
|
158
|
-
});
|
|
159
|
-
});
|
|
160
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
161
|
-
// HTTP routes
|
|
162
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
163
|
-
describe('GET /onesub/entitlement', () => {
|
|
164
|
-
it('returns active=true when user has matching active sub', async () => {
|
|
165
|
-
const { store, server } = buildServer({ entitlements: PREMIUM });
|
|
166
|
-
await store.save(sub({ userId: 'u_yes', productId: 'pro_monthly' }));
|
|
167
|
-
const resp = await server.get('/onesub/entitlement?userId=u_yes&id=premium');
|
|
168
|
-
expect(resp.status).toBe(200);
|
|
169
|
-
expect(resp.body.id).toBe('premium');
|
|
170
|
-
expect(resp.body.active).toBe(true);
|
|
171
|
-
expect(resp.body.source).toBe('subscription');
|
|
172
|
-
});
|
|
173
|
-
it('returns active=false when no matching record', async () => {
|
|
174
|
-
const { server } = buildServer({ entitlements: PREMIUM });
|
|
175
|
-
const resp = await server.get('/onesub/entitlement?userId=u_none&id=premium');
|
|
176
|
-
expect(resp.status).toBe(200);
|
|
177
|
-
expect(resp.body.active).toBe(false);
|
|
178
|
-
expect(resp.body.source).toBeNull();
|
|
179
|
-
});
|
|
180
|
-
it('404 + ENTITLEMENT_NOT_FOUND for unknown id', async () => {
|
|
181
|
-
const { server } = buildServer({ entitlements: PREMIUM });
|
|
182
|
-
const resp = await server.get('/onesub/entitlement?userId=u&id=enterprise');
|
|
183
|
-
expect(resp.status).toBe(404);
|
|
184
|
-
expect(resp.body.errorCode).toBe('ENTITLEMENT_NOT_FOUND');
|
|
185
|
-
});
|
|
186
|
-
it('400 INVALID_INPUT when userId or id is missing', async () => {
|
|
187
|
-
const { server } = buildServer({ entitlements: PREMIUM });
|
|
188
|
-
const resp = await server.get('/onesub/entitlement?userId=u');
|
|
189
|
-
expect(resp.status).toBe(400);
|
|
190
|
-
expect(resp.body.errorCode).toBe('INVALID_INPUT');
|
|
191
|
-
});
|
|
192
|
-
});
|
|
193
|
-
describe('GET /onesub/entitlements (bulk)', () => {
|
|
194
|
-
it('evaluates every configured entitlement in one round-trip', async () => {
|
|
195
|
-
const config = {
|
|
196
|
-
premium: { productIds: ['pro_monthly', 'pro_yearly'] },
|
|
197
|
-
promode: { productIds: ['dev_tools_addon'] },
|
|
198
|
-
};
|
|
199
|
-
const { store, server } = buildServer({ entitlements: config });
|
|
200
|
-
await store.save(sub({ userId: 'u_bulk', productId: 'pro_yearly' }));
|
|
201
|
-
const resp = await server.get('/onesub/entitlements?userId=u_bulk');
|
|
202
|
-
expect(resp.status).toBe(200);
|
|
203
|
-
expect(resp.body.entitlements.premium.active).toBe(true);
|
|
204
|
-
expect(resp.body.entitlements.premium.productId).toBe('pro_yearly');
|
|
205
|
-
expect(resp.body.entitlements.promode.active).toBe(false);
|
|
206
|
-
});
|
|
207
|
-
it('returns empty entitlement statuses for a user with nothing', async () => {
|
|
208
|
-
const config = {
|
|
209
|
-
premium: { productIds: ['pro_monthly'] },
|
|
210
|
-
addon: { productIds: ['some_addon'] },
|
|
211
|
-
};
|
|
212
|
-
const { server } = buildServer({ entitlements: config });
|
|
213
|
-
const resp = await server.get('/onesub/entitlements?userId=u_empty');
|
|
214
|
-
expect(resp.body.entitlements.premium.active).toBe(false);
|
|
215
|
-
expect(resp.body.entitlements.addon.active).toBe(false);
|
|
216
|
-
});
|
|
217
|
-
});
|
|
218
|
-
describe('Entitlement routes are NOT mounted without config', () => {
|
|
219
|
-
it('returns 404 when config.entitlements is absent', async () => {
|
|
220
|
-
const { server } = buildServer({}); // no entitlements
|
|
221
|
-
const resp = await server.get('/onesub/entitlement?userId=u&id=premium');
|
|
222
|
-
expect(resp.status).toBe(404);
|
|
223
|
-
const respBulk = await server.get('/onesub/entitlements?userId=u');
|
|
224
|
-
expect(respBulk.status).toBe(404);
|
|
225
|
-
});
|
|
226
|
-
it('returns 404 when config.entitlements is empty {}', async () => {
|
|
227
|
-
const { server } = buildServer({ entitlements: {} });
|
|
228
|
-
const resp = await server.get('/onesub/entitlement?userId=u&id=premium');
|
|
229
|
-
expect(resp.status).toBe(404);
|
|
230
|
-
});
|
|
231
|
-
});
|
|
232
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
233
|
-
// Store regression: getAllByUserId returns multi-product subs
|
|
234
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
235
|
-
describe('InMemorySubscriptionStore — multi-sub per user (entitlement support)', () => {
|
|
236
|
-
it('getAllByUserId returns all subs across productIds, latest first', async () => {
|
|
237
|
-
const store = new InMemorySubscriptionStore();
|
|
238
|
-
await store.save(sub({ userId: 'u', productId: 'pro_monthly', originalTransactionId: 'orig_a' }));
|
|
239
|
-
await store.save(sub({ userId: 'u', productId: 'pro_yearly', originalTransactionId: 'orig_b' }));
|
|
240
|
-
await store.save(sub({ userId: 'u', productId: 'addon', originalTransactionId: 'orig_c' }));
|
|
241
|
-
const all = await store.getAllByUserId('u');
|
|
242
|
-
expect(all.map((s) => s.productId)).toEqual(['addon', 'pro_yearly', 'pro_monthly']);
|
|
243
|
-
});
|
|
244
|
-
it('getByUserId still returns the most recent (legacy contract preserved)', async () => {
|
|
245
|
-
const store = new InMemorySubscriptionStore();
|
|
246
|
-
await store.save(sub({ userId: 'u', productId: 'pro_monthly', originalTransactionId: 'orig_a' }));
|
|
247
|
-
await store.save(sub({ userId: 'u', productId: 'pro_yearly', originalTransactionId: 'orig_b' }));
|
|
248
|
-
const latest = await store.getByUserId('u');
|
|
249
|
-
expect(latest?.productId).toBe('pro_yearly');
|
|
250
|
-
});
|
|
251
|
-
it('save() with same originalTransactionId replaces prior record (no duplicates)', async () => {
|
|
252
|
-
const store = new InMemorySubscriptionStore();
|
|
253
|
-
await store.save(sub({
|
|
254
|
-
userId: 'u', productId: 'pro_monthly', originalTransactionId: 'orig_x',
|
|
255
|
-
status: 'active',
|
|
256
|
-
}));
|
|
257
|
-
await store.save(sub({
|
|
258
|
-
userId: 'u', productId: 'pro_monthly', originalTransactionId: 'orig_x',
|
|
259
|
-
status: 'canceled',
|
|
260
|
-
}));
|
|
261
|
-
const all = await store.getAllByUserId('u');
|
|
262
|
-
expect(all).toHaveLength(1);
|
|
263
|
-
expect(all[0].status).toBe(SUBSCRIPTION_STATUS.CANCELED);
|
|
264
|
-
});
|
|
265
|
-
});
|
|
266
|
-
// keep the import honest
|
|
267
|
-
PURCHASE_TYPE;
|
|
268
|
-
//# sourceMappingURL=entitlements.test.js.map
|