payment-kit 1.29.0 → 1.29.2
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/dev.ts +41 -2
- package/api/hono.d.ts +42 -0
- package/api/node-sqlite.d.ts +12 -0
- package/api/src/bootstrap.ts +36 -0
- package/api/src/crons/base.ts +3 -3
- package/api/src/crons/currency.ts +1 -1
- package/api/src/crons/index.ts +27 -24
- package/api/src/crons/metering-subscription-detection.ts +1 -1
- package/api/src/crons/overdue-detection.ts +2 -2
- package/api/src/crons/retry-pending-events.ts +6 -0
- package/api/src/index.ts +22 -161
- package/api/src/integrations/app-store/client.ts +3 -4
- package/api/src/integrations/app-store/handlers/subscription.ts +7 -7
- package/api/src/integrations/app-store/signed-data-verifier.ts +3 -2
- package/api/src/integrations/arcblock/token.ts +21 -7
- package/api/src/integrations/google-play/handlers/subscription.ts +6 -6
- package/api/src/integrations/google-play/handlers/voided.ts +2 -2
- package/api/src/integrations/google-play/verify.ts +3 -2
- package/api/src/integrations/iap-reconcile.ts +3 -5
- package/api/src/integrations/stripe/handlers/invoice.ts +2 -2
- package/api/src/integrations/stripe/handlers/subscription.ts +3 -3
- package/api/src/libs/archive/query.ts +19 -0
- package/api/src/libs/audit.ts +61 -4
- package/api/src/libs/auth.ts +99 -38
- package/api/src/libs/context.ts +78 -1
- package/api/src/libs/currency.ts +2 -2
- package/api/src/libs/dayjs.ts +8 -2
- package/api/src/libs/drivers/auth-storage.ts +118 -0
- package/api/src/libs/drivers/cron.ts +264 -0
- package/api/src/libs/drivers/db.ts +170 -0
- package/api/src/libs/drivers/identity.ts +81 -0
- package/api/src/libs/drivers/index.ts +40 -0
- package/api/src/libs/drivers/locks.ts +226 -0
- package/api/src/libs/drivers/migrate-runner.ts +70 -0
- package/api/src/libs/drivers/queue.ts +104 -0
- package/api/src/libs/drivers/secrets.ts +194 -0
- package/api/src/libs/env.ts +170 -54
- package/api/src/libs/exchange-rate/service.ts +7 -6
- package/api/src/libs/http-fetch-adapter.ts +50 -0
- package/api/src/libs/invoice.ts +1 -1
- package/api/src/libs/lock.ts +51 -47
- package/api/src/libs/logger.ts +48 -8
- package/api/src/libs/notification/index.ts +1 -1
- package/api/src/libs/notification/template/customer-credit-low-balance.ts +2 -1
- package/api/src/libs/notification/template/customer-revenue-succeeded.ts +1 -1
- package/api/src/libs/notification/template/customer-reward-succeeded.ts +1 -1
- package/api/src/libs/overdraft-protection.ts +1 -1
- package/api/src/libs/payout.ts +1 -1
- package/api/src/libs/queue/index.ts +259 -52
- package/api/src/libs/queue/runtime.ts +175 -0
- package/api/src/libs/resource.ts +3 -3
- package/api/src/libs/secrets.ts +38 -0
- package/api/src/libs/session.ts +3 -2
- package/api/src/libs/subscription.ts +5 -5
- package/api/src/libs/tenant.ts +92 -0
- package/api/src/libs/url.ts +3 -3
- package/api/src/libs/util.ts +21 -13
- package/api/src/middlewares/hono/cdn.ts +63 -0
- package/api/src/middlewares/hono/context.ts +73 -0
- package/api/src/middlewares/hono/csrf.ts +72 -0
- package/api/src/middlewares/hono/fallback.ts +194 -0
- package/api/src/middlewares/hono/pipeline.ts +73 -0
- package/api/src/middlewares/hono/resource-mount.ts +42 -0
- package/api/src/middlewares/hono/resource.ts +63 -0
- package/api/src/middlewares/hono/security.ts +214 -0
- package/api/src/middlewares/hono/session.ts +114 -0
- package/api/src/middlewares/hono/xss.ts +61 -0
- package/api/src/queues/auto-recharge.ts +12 -10
- package/api/src/queues/checkout-session.ts +17 -12
- package/api/src/queues/credit-consume.ts +40 -36
- package/api/src/queues/credit-grant.ts +25 -18
- package/api/src/queues/credit-reconciliation.ts +7 -5
- package/api/src/queues/discount-status.ts +9 -6
- package/api/src/queues/event.ts +12 -4
- package/api/src/queues/exchange-rate-health.ts +49 -30
- package/api/src/queues/invoice.ts +18 -15
- package/api/src/queues/notification.ts +14 -7
- package/api/src/queues/payment.ts +41 -28
- package/api/src/queues/payout.ts +9 -5
- package/api/src/queues/refund.ts +18 -12
- package/api/src/queues/subscription.ts +83 -53
- package/api/src/queues/token-transfer.ts +15 -10
- package/api/src/queues/usage-record.ts +8 -5
- package/api/src/queues/vendors/commission.ts +7 -5
- package/api/src/queues/vendors/fulfillment-coordinator.ts +17 -13
- package/api/src/queues/vendors/fulfillment.ts +4 -2
- package/api/src/queues/vendors/return-processor.ts +5 -3
- package/api/src/queues/vendors/return-scanner.ts +5 -4
- package/api/src/queues/vendors/status-check.ts +10 -7
- package/api/src/queues/webhook.ts +60 -32
- package/api/src/routes/connect/shared.ts +1 -2
- package/api/src/routes/connect/subscribe.ts +3 -3
- package/api/src/routes/{archive.ts → hono/archive.ts} +69 -64
- package/api/src/routes/{auto-recharge-configs.ts → hono/auto-recharge-configs.ts} +39 -28
- package/api/src/routes/{checkout-sessions.ts → hono/checkout-sessions.ts} +790 -923
- package/api/src/routes/{coupons.ts → hono/coupons.ts} +93 -76
- package/api/src/routes/{credit-grants.ts → hono/credit-grants.ts} +140 -126
- package/api/src/routes/hono/credit-tokens.ts +43 -0
- package/api/src/routes/{credit-transactions.ts → hono/credit-transactions.ts} +37 -29
- package/api/src/routes/{customers.ts → hono/customers.ts} +193 -223
- package/api/src/routes/{donations.ts → hono/donations.ts} +41 -32
- package/api/src/routes/{entitlements.ts → hono/entitlements.ts} +28 -25
- package/api/src/routes/{events.ts → hono/events.ts} +107 -71
- package/api/src/routes/{exchange-rate-providers.ts → hono/exchange-rate-providers.ts} +138 -126
- package/api/src/routes/hono/exchange-rates.ts +77 -0
- package/api/src/routes/hono/index.ts +115 -0
- package/api/src/routes/{integrations → hono/integrations}/app-store.ts +68 -48
- package/api/src/routes/{integrations → hono/integrations}/google-play.ts +78 -58
- package/api/src/routes/hono/integrations/stripe.ts +74 -0
- package/api/src/routes/{invoices.ts → hono/invoices.ts} +253 -244
- package/api/src/routes/{meter-events.ts → hono/meter-events.ts} +120 -110
- package/api/src/routes/hono/meters.ts +288 -0
- package/api/src/routes/hono/passports.ts +73 -0
- package/api/src/routes/{payment-currencies.ts → hono/payment-currencies.ts} +219 -197
- package/api/src/routes/{payment-intents.ts → hono/payment-intents.ts} +136 -132
- package/api/src/routes/{payment-links.ts → hono/payment-links.ts} +145 -128
- package/api/src/routes/{payment-methods.ts → hono/payment-methods.ts} +125 -93
- package/api/src/routes/{payment-stats.ts → hono/payment-stats.ts} +30 -25
- package/api/src/routes/{payouts.ts → hono/payouts.ts} +55 -47
- package/api/src/routes/{prices.ts → hono/prices.ts} +265 -242
- package/api/src/routes/{pricing-table.ts → hono/pricing-table.ts} +94 -87
- package/api/src/routes/{products.ts → hono/products.ts} +172 -159
- package/api/src/routes/{promotion-codes.ts → hono/promotion-codes.ts} +207 -185
- package/api/src/routes/hono/redirect.ts +24 -0
- package/api/src/routes/{refunds.ts → hono/refunds.ts} +96 -80
- package/api/src/routes/{settings.ts → hono/settings.ts} +64 -55
- package/api/src/routes/{subscription-items.ts → hono/subscription-items.ts} +64 -57
- package/api/src/routes/{subscriptions.ts → hono/subscriptions.ts} +475 -528
- package/api/src/routes/{tax-rates.ts → hono/tax-rates.ts} +71 -70
- package/api/src/routes/hono/tool.ts +69 -0
- package/api/src/routes/{usage-records.ts → hono/usage-records.ts} +47 -42
- package/api/src/routes/{vendor.ts → hono/vendor.ts} +315 -167
- package/api/src/routes/{webhook-attempts.ts → hono/webhook-attempts.ts} +17 -13
- package/api/src/routes/hono/webhook-endpoints.ts +126 -0
- package/api/src/service.ts +667 -0
- package/api/src/store/migrations/20230911-seeding.ts +2 -1
- package/api/src/store/migrations/20260609-remove-did-space-jobs.ts +23 -0
- package/api/src/store/migrations/20260610-tenant-columns.ts +40 -0
- package/api/src/store/migrations/20260611-tenant-backfill.ts +33 -0
- package/api/src/store/models/auto-recharge-config.ts +22 -10
- package/api/src/store/models/checkout-session.ts +15 -14
- package/api/src/store/models/coupon.ts +29 -20
- package/api/src/store/models/credit-grant.ts +38 -29
- package/api/src/store/models/credit-transaction.ts +32 -21
- package/api/src/store/models/customer.ts +19 -17
- package/api/src/store/models/discount.ts +11 -2
- package/api/src/store/models/entitlement-grant.ts +21 -9
- package/api/src/store/models/entitlement-product.ts +21 -9
- package/api/src/store/models/entitlement.ts +19 -10
- package/api/src/store/models/event.ts +18 -9
- package/api/src/store/models/exchange-rate-provider.ts +17 -4
- package/api/src/store/models/invoice-item.ts +18 -9
- package/api/src/store/models/invoice.ts +16 -8
- package/api/src/store/models/meter-event.ts +27 -9
- package/api/src/store/models/meter.ts +31 -22
- package/api/src/store/models/payment-currency.ts +25 -8
- package/api/src/store/models/payment-intent.ts +15 -6
- package/api/src/store/models/payment-link.ts +15 -6
- package/api/src/store/models/payment-method.ts +38 -22
- package/api/src/store/models/payment-stat.ts +18 -9
- package/api/src/store/models/payout.ts +15 -6
- package/api/src/store/models/price-quote.ts +17 -8
- package/api/src/store/models/price.ts +24 -12
- package/api/src/store/models/pricing-table.ts +29 -20
- package/api/src/store/models/product-vendor.ts +20 -10
- package/api/src/store/models/product.ts +15 -6
- package/api/src/store/models/promotion-code.ts +14 -6
- package/api/src/store/models/refund.ts +15 -6
- package/api/src/store/models/revenue-snapshot.ts +21 -9
- package/api/src/store/models/setting.ts +18 -9
- package/api/src/store/models/setup-intent.ts +36 -27
- package/api/src/store/models/subscription-item.ts +21 -9
- package/api/src/store/models/subscription-schedule.ts +21 -9
- package/api/src/store/models/subscription.ts +21 -10
- package/api/src/store/models/tax-rate.ts +29 -21
- package/api/src/store/models/usage-record.ts +11 -2
- package/api/src/store/models/webhook-attempt.ts +18 -9
- package/api/src/store/models/webhook-endpoint.ts +18 -9
- package/api/src/store/scoped-core.ts +55 -0
- package/api/src/store/scoped.ts +247 -0
- package/api/src/store/sequelize.ts +66 -22
- package/api/src/store/sql-migrations.ts +20 -0
- package/api/src/store/tenant-backfill.ts +260 -0
- package/api/src/store/tenant-model.ts +124 -0
- package/api/src/store/tenant-tables.ts +50 -0
- package/api/tests/embedded/embedded-multi-mode-d3.spec.ts +257 -0
- package/api/tests/fixtures/bare-query-violation.ts +13 -0
- package/api/tests/fixtures/core-env-violation.ts +10 -0
- package/api/tests/fixtures/host-read-violation.ts +19 -0
- package/api/tests/fixtures/tenants.ts +4 -0
- package/api/tests/integrations/iap-tenant.spec.ts +284 -0
- package/api/tests/libs/archive-query.spec.ts +26 -0
- package/api/tests/libs/audit-tenant.spec.ts +153 -0
- package/api/tests/libs/context.spec.ts +204 -0
- package/api/tests/libs/core-config.spec.ts +115 -0
- package/api/tests/libs/cron-driver-d2.spec.ts +237 -0
- package/api/tests/libs/crons-conservation-d2.spec.ts +52 -0
- package/api/tests/libs/lock-tenant.spec.ts +66 -0
- package/api/tests/libs/scoped.spec.ts +222 -0
- package/api/tests/libs/secrets-facade.spec.ts +52 -0
- package/api/tests/libs/tenancy-slot-authority.spec.ts +209 -0
- package/api/tests/libs/tenant-middleware.spec.ts +42 -0
- package/api/tests/libs/tenant-scanner.spec.ts +120 -0
- package/api/tests/middlewares/hono/cdn.spec.ts +70 -0
- package/api/tests/middlewares/hono/context.spec.ts +113 -0
- package/api/tests/middlewares/hono/csrf.spec.ts +136 -0
- package/api/tests/middlewares/hono/fallback.spec.ts +67 -0
- package/api/tests/middlewares/hono/pipeline.spec.ts +47 -0
- package/api/tests/middlewares/hono/security.spec.ts +181 -0
- package/api/tests/middlewares/hono/session.spec.ts +42 -0
- package/api/tests/middlewares/hono/xss.spec.ts +81 -0
- package/api/tests/models/tenant-backfill.spec.ts +287 -0
- package/api/tests/models/tenant-columns-model.spec.ts +46 -0
- package/api/tests/models/tenant-columns.spec.ts +161 -0
- package/api/tests/queues/credit-consume-batch.spec.ts +8 -1
- package/api/tests/queues/credit-consume.spec.ts +8 -1
- package/api/tests/queues/event-tenant.spec.ts +236 -0
- package/api/tests/queues/exchange-rate-health-tenant-d6.spec.ts +62 -0
- package/api/tests/queues/queue-parity.spec.ts +249 -0
- package/api/tests/queues/queue-runtime-surface.spec.ts +277 -0
- package/api/tests/queues/queue-teardown-d2.spec.ts +127 -0
- package/api/tests/queues/tenant-matrix-a.spec.ts +245 -0
- package/api/tests/queues/tenant-matrix-b.spec.ts +168 -0
- package/api/tests/routes/connect/hono-attach.spec.ts +107 -0
- package/api/tests/service/collapse.spec.ts +96 -0
- package/api/tests/store/tenant-crosscut.spec.ts +202 -0
- package/api/tests/store/tenant-model-spike.spec.ts +177 -0
- package/api/tests/store/tenant-model.spec.ts +162 -0
- package/api/tests/store/tenant-residual.spec.ts +196 -0
- package/api/third.d.ts +4 -0
- package/blocklet.yml +1 -1
- package/cloudflare/README.md +26 -6
- package/cloudflare/build.ts +28 -13
- package/cloudflare/did-connect-auth.ts +0 -217
- package/cloudflare/docs/2026-06-10-bundle-size-analysis.md +288 -0
- package/cloudflare/migrations/0006_tenant_columns.sql +46 -0
- package/cloudflare/migrations/0007_tenant_backfill_indexes.sql +65 -0
- package/cloudflare/migrations/0008_schema_parity.sql +16 -0
- package/cloudflare/migrations/0009_remove_did_space_jobs.sql +5 -0
- package/cloudflare/queue-runtime-mode.ts +13 -0
- package/cloudflare/run-build.js +31 -56
- package/cloudflare/shims/blocklet-sdk/asset-host-transformer.ts +20 -0
- package/cloudflare/shims/blocklet-sdk/config.ts +8 -1
- package/cloudflare/shims/blocklet-sdk/login.ts +12 -0
- package/cloudflare/shims/blocklet-sdk/service-api.ts +14 -0
- package/cloudflare/shims/blocklet-sdk/session.ts +4 -2
- package/cloudflare/shims/blocklet-sdk/util-constants.ts +8 -0
- package/cloudflare/shims/blocklet-sdk/util-csrf.ts +13 -0
- package/cloudflare/shims/blocklet-sdk/util-wallet.ts +8 -0
- package/cloudflare/shims/cron.ts +38 -158
- package/cloudflare/shims/events.ts +124 -0
- package/cloudflare/shims/fastq.ts +15 -1
- package/cloudflare/shims/nedb-storage.ts +16 -8
- package/cloudflare/shims/node-fetch.ts +35 -0
- package/cloudflare/shims/xss.ts +8 -0
- package/cloudflare/tenant-middleware.ts +36 -0
- package/cloudflare/tests/tenant-middleware.spec.ts +160 -0
- package/cloudflare/tests/worker-handler-gate.spec.ts +44 -0
- package/cloudflare/worker.ts +204 -433
- package/cloudflare/wrangler.local-e2e.jsonc +26 -0
- package/jest.config.js +3 -1
- package/package.json +33 -38
- package/scripts/core-env-whitelist.json +1 -0
- package/scripts/e2e-12b-runtime.ts +149 -0
- package/scripts/e2e-core-config.ts +125 -0
- package/scripts/e2e-d1-tenancy.ts +116 -0
- package/scripts/e2e-d2-cron-queue.ts +139 -0
- package/scripts/e2e-d3-embedded-multi.ts +171 -0
- package/scripts/e2e-hono-s2.ts +125 -0
- package/scripts/e2e-hono-s3e.ts +135 -0
- package/scripts/e2e-hono-s4.ts +114 -0
- package/scripts/e2e-migration-contract.ts +100 -0
- package/scripts/e2e-s0.ts +61 -0
- package/scripts/e2e-s1.ts +107 -0
- package/scripts/e2e-s2.ts +178 -0
- package/scripts/e2e-s3.ts +110 -0
- package/scripts/e2e-s4.ts +191 -0
- package/scripts/e2e-s5.ts +139 -0
- package/scripts/e2e-s6.ts +127 -0
- package/scripts/e2e-tenant-model.ts +119 -0
- package/scripts/e2e-tenant-worker.ts +199 -0
- package/scripts/gen-sql-migrations.js +46 -0
- package/scripts/phase8-codemod.js +219 -0
- package/scripts/phase9a-env-getters-codemod.js +82 -0
- package/scripts/scan-core-env.js +109 -0
- package/scripts/scan-tenant-queries.js +235 -0
- package/scripts/schema-drift-guard.ts +210 -0
- package/scripts/tenant-scan-whitelist.json +1 -0
- package/src/env.d.ts +13 -1
- package/tsconfig.json +1 -1
- package/api/src/libs/did-space.ts +0 -235
- package/api/src/libs/middleware.ts +0 -50
- package/api/src/libs/security.ts +0 -192
- package/api/src/queues/space.ts +0 -662
- package/api/src/routes/credit-tokens.ts +0 -38
- package/api/src/routes/exchange-rates.ts +0 -87
- package/api/src/routes/index.ts +0 -142
- package/api/src/routes/integrations/stripe.ts +0 -61
- package/api/src/routes/meters.ts +0 -274
- package/api/src/routes/passports.ts +0 -68
- package/api/src/routes/redirect.ts +0 -20
- package/api/src/routes/tool.ts +0 -65
- package/api/src/routes/webhook-endpoints.ts +0 -126
- package/api/tests/routes/credit-grants.spec.ts +0 -1261
- package/cloudflare/shims/did-space-js.ts +0 -17
- package/cloudflare/shims/did-space.ts +0 -11
- package/cloudflare/shims/express-compat/index.ts +0 -80
- package/cloudflare/shims/express-compat/types.ts +0 -41
- package/cloudflare/shims/lock.ts +0 -115
- package/cloudflare/shims/queue.ts +0 -611
- package/cloudflare/tests/shims/queue-delayed-persist.spec.ts +0 -87
- package/cloudflare/tests/shims/queue-scheduled.spec.ts +0 -186
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// D3 (S3.0) — embedded multi-mode background harness. Builds a REAL embedded
|
|
2
|
+
// payment service (createEmbeddedPaymentService) over a file-backed sqlite with
|
|
3
|
+
// the full 46-table schema (applyPaymentCoreMigrations) and tenancy:{mode:'multi'}
|
|
4
|
+
// + every slot, then exercises the background engine the way an arc-node host
|
|
5
|
+
// drives it: queue retry, delayed-job restart recovery, cross-tenant isolation,
|
|
6
|
+
// and slot-misconfig fail-closed.
|
|
7
|
+
//
|
|
8
|
+
// The engine-level retry/dispatch parity lives in queue-runtime-surface.spec;
|
|
9
|
+
// this harness proves the SAME behavior holds through the assembled multi-mode
|
|
10
|
+
// service (models bound by the factory, tenant carried in the payload).
|
|
11
|
+
|
|
12
|
+
import fs from 'fs';
|
|
13
|
+
import os from 'os';
|
|
14
|
+
import path from 'path';
|
|
15
|
+
import { Sequelize } from 'sequelize';
|
|
16
|
+
|
|
17
|
+
import { withTenant, getInstanceDid } from '../../src/libs/context';
|
|
18
|
+
import { createNodeDbDriver } from '../../src/libs/drivers/db';
|
|
19
|
+
import {
|
|
20
|
+
applyPaymentCoreMigrations,
|
|
21
|
+
createMemoryLocksDriver,
|
|
22
|
+
createCronRegistry,
|
|
23
|
+
createKeyringSecretsDriver,
|
|
24
|
+
nodeQueueHostHooks,
|
|
25
|
+
} from '../../src/libs/drivers';
|
|
26
|
+
import { setQueueRuntimeMode, __test__ as runtimeTest } from '../../src/libs/queue/runtime';
|
|
27
|
+
import { createEmbeddedPaymentService, PaymentCoreSlotError } from '../../src/service';
|
|
28
|
+
|
|
29
|
+
jest.setTimeout(60000);
|
|
30
|
+
|
|
31
|
+
jest.mock('../../src/libs/logger', () => ({
|
|
32
|
+
__esModule: true,
|
|
33
|
+
default: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
const TENANT_A = 'did:abt:zD3TENANTA';
|
|
37
|
+
const TENANT_B = 'did:abt:zD3TENANTB';
|
|
38
|
+
const HOST_A = 'a.example.com';
|
|
39
|
+
const HOST_B = 'b.example.com';
|
|
40
|
+
|
|
41
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'embedded-multi-d3-'));
|
|
42
|
+
const dbFile = path.join(dir, 'payment.db');
|
|
43
|
+
const sequelize = new Sequelize({ dialect: 'sqlite', storage: dbFile, logging: false, pool: { max: 5 } });
|
|
44
|
+
|
|
45
|
+
// multi-tenant identity: Host -> tenant, plus a per-tenant EK for the keyring.
|
|
46
|
+
const identity = {
|
|
47
|
+
resolveInstanceDidForHost(host: string | undefined) {
|
|
48
|
+
if (host === HOST_A) return TENANT_A;
|
|
49
|
+
if (host === HOST_B) return TENANT_B;
|
|
50
|
+
return null; // unknown host fails closed at the resolver
|
|
51
|
+
},
|
|
52
|
+
getAppEk(instanceDid: string) {
|
|
53
|
+
// distinct per-tenant key material (hex) — the keyring isolates by this
|
|
54
|
+
return instanceDid === TENANT_A ? 'a'.repeat(64) : 'b'.repeat(64);
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const baseSlots = () => ({
|
|
59
|
+
config: { BLOCKLET_APP_PID: TENANT_A, PAYMENT_LIVEMODE: 'true' },
|
|
60
|
+
db: { sequelize: sequelize as any },
|
|
61
|
+
tenancy: { mode: 'multi' as const },
|
|
62
|
+
identity,
|
|
63
|
+
secrets: createKeyringSecretsDriver(identity),
|
|
64
|
+
queue: nodeQueueHostHooks,
|
|
65
|
+
cron: createCronRegistry('node-cron'),
|
|
66
|
+
locks: createMemoryLocksDriver(),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
let svc: ReturnType<typeof createEmbeddedPaymentService>;
|
|
70
|
+
let createQueue: any;
|
|
71
|
+
let createQueueStore: any;
|
|
72
|
+
let tableCount = 0;
|
|
73
|
+
|
|
74
|
+
const settle = (emitter: any): Promise<string> =>
|
|
75
|
+
new Promise((resolve) => {
|
|
76
|
+
['finished', 'failed', 'cancelled'].forEach((e) => emitter.on(e, () => resolve(e)));
|
|
77
|
+
});
|
|
78
|
+
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
79
|
+
|
|
80
|
+
beforeAll(async () => {
|
|
81
|
+
// provision the full embedded schema (9 SQL migrations -> 46 tables)
|
|
82
|
+
const driver = createNodeDbDriver(sequelize);
|
|
83
|
+
await applyPaymentCoreMigrations(driver);
|
|
84
|
+
// canonical embedded-schema count (task 13 / D4): 46 tables INCLUDING the
|
|
85
|
+
// _sql_migrations tracker row table.
|
|
86
|
+
const rows = await driver.all<{ name: string }>(
|
|
87
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
|
88
|
+
);
|
|
89
|
+
tableCount = rows.length;
|
|
90
|
+
|
|
91
|
+
// assemble the multi-mode embedded service (binds models to OUR sequelize)
|
|
92
|
+
svc = createEmbeddedPaymentService(baseSlots());
|
|
93
|
+
setQueueRuntimeMode('node');
|
|
94
|
+
createQueue = require('../../src/libs/queue').default;
|
|
95
|
+
createQueueStore = require('../../src/libs/queue/store').default;
|
|
96
|
+
}, 120000);
|
|
97
|
+
|
|
98
|
+
afterAll(async () => {
|
|
99
|
+
// exercise the D2 lifecycle teardown contract (no-op here since we never
|
|
100
|
+
// called lifecycle.start(), but it must not throw and clears any driver state)
|
|
101
|
+
await svc.lifecycle.stop();
|
|
102
|
+
runtimeTest.reset();
|
|
103
|
+
await sequelize.close();
|
|
104
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
afterEach(() => {
|
|
108
|
+
runtimeTest.reset();
|
|
109
|
+
setQueueRuntimeMode('node');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('D3 happy path — multi service assembles + a job runs under its tenant', () => {
|
|
113
|
+
it('the embedded schema has 46 tables and the rpc surface is present', () => {
|
|
114
|
+
expect(tableCount).toBe(46);
|
|
115
|
+
expect(typeof svc.rpc.entitlements.check).toBe('function');
|
|
116
|
+
expect(typeof svc.rpc.meterEvents.report).toBe('function');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('a job enqueued under tenant A executes under tenant A', async () => {
|
|
120
|
+
let observed = '';
|
|
121
|
+
const q = createQueue({
|
|
122
|
+
name: `d3-happy-${Date.now()}`,
|
|
123
|
+
onJob: async () => {
|
|
124
|
+
observed = getInstanceDid();
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
const ev = await withTenant(TENANT_A, async () => q.push({ job: { v: 1, instance_did: TENANT_A } }));
|
|
128
|
+
expect(await settle(ev)).toBe('finished');
|
|
129
|
+
expect(observed).toBe(TENANT_A);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('D3 bad input — slot misconfig fails closed (no silent single)', () => {
|
|
134
|
+
it('multi without a secrets slot throws PaymentCoreSlotError', () => {
|
|
135
|
+
const slots: any = baseSlots();
|
|
136
|
+
delete slots.secrets;
|
|
137
|
+
expect(() => createEmbeddedPaymentService(slots)).toThrow(PaymentCoreSlotError);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('multi without an identity slot throws PaymentCoreSlotError', () => {
|
|
141
|
+
const slots: any = baseSlots();
|
|
142
|
+
delete slots.identity;
|
|
143
|
+
expect(() => createEmbeddedPaymentService(slots)).toThrow(PaymentCoreSlotError);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('multi without cron/queue/locks still assembles (safe Node defaults, not a degrade)', () => {
|
|
147
|
+
// identity + secrets are the ONLY slots whose absence silently degrades to
|
|
148
|
+
// single-tenant (D1), so they fail closed. cron/queue/locks have correct
|
|
149
|
+
// Node defaults (node-cron registry, no-op flush, memory locks) for a
|
|
150
|
+
// single-process daemon, so their absence is NOT an error — it must not be
|
|
151
|
+
// conflated with the identity/secrets fail-closed case.
|
|
152
|
+
const slots: any = baseSlots();
|
|
153
|
+
delete slots.cron;
|
|
154
|
+
delete slots.queue;
|
|
155
|
+
delete slots.locks;
|
|
156
|
+
expect(() => createEmbeddedPaymentService(slots)).not.toThrow();
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe('D3 security — cross-tenant job object is fail-closed', () => {
|
|
161
|
+
it('a job whose loaded object tenant != payload tenant is rejected (structured)', async () => {
|
|
162
|
+
const { assertJobObjectTenant } = require('../../src/libs/queue');
|
|
163
|
+
let leaked = false;
|
|
164
|
+
let observed = '';
|
|
165
|
+
const q = createQueue({
|
|
166
|
+
name: `d3-xtenant-${Date.now()}`,
|
|
167
|
+
onJob: async () => {
|
|
168
|
+
observed = getInstanceDid();
|
|
169
|
+
// payload says A; an object loaded cross-tenant belongs to B
|
|
170
|
+
assertJobObjectTenant({ instance_did: TENANT_B });
|
|
171
|
+
leaked = true; // unreachable — assert throws first
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
const ev = await withTenant(TENANT_A, async () => q.push({ job: { tag: 'x', instance_did: TENANT_A } }));
|
|
175
|
+
expect(await settle(ev)).toBe('failed');
|
|
176
|
+
expect(observed).toBe(TENANT_A);
|
|
177
|
+
expect(leaked).toBe(false);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe('D3 data loss — delayed/persisted job recovers on restart', () => {
|
|
182
|
+
it('a persisted row left by a prior queue is recovered + executed by a fresh queue (same id)', async () => {
|
|
183
|
+
const name = `d3-recover-${Date.now()}`;
|
|
184
|
+
const jobId = `recover-${Date.now()}`;
|
|
185
|
+
// simulate a prior process: a persisted immediate row sits in the store,
|
|
186
|
+
// never executed (no live queue ran it)
|
|
187
|
+
const store = createQueueStore(name);
|
|
188
|
+
await store.addJob(jobId, { v: 1, instance_did: TENANT_A }, {});
|
|
189
|
+
|
|
190
|
+
// "restart": a fresh queue with the same name boots and recovers the row.
|
|
191
|
+
// Capture the RECOVERED row's id from the runtime's finished event (the
|
|
192
|
+
// job payload carries no id, so this is the only faithful source — proving
|
|
193
|
+
// the recovered id is the original, not a freshly generated one).
|
|
194
|
+
let ranId = '';
|
|
195
|
+
let executions = 0;
|
|
196
|
+
const q = createQueue({
|
|
197
|
+
name,
|
|
198
|
+
onJob: async () => {
|
|
199
|
+
executions += 1;
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
q.on('finished', (data: { id: string }) => {
|
|
203
|
+
ranId = data.id;
|
|
204
|
+
});
|
|
205
|
+
// loadExisting runs on process.nextTick; give it room to recover + execute
|
|
206
|
+
await wait(400);
|
|
207
|
+
const remaining = await store.getJobs();
|
|
208
|
+
expect(ranId).toBe(jobId); // the RECOVERED row's id is the original (not regenerated)
|
|
209
|
+
expect(executions).toBe(1); // executed exactly once on recovery
|
|
210
|
+
expect(remaining.find((r: any) => r.id === jobId)).toBeUndefined(); // consumed, not duplicated
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe('D3 data damage — retry does not double-execute (idempotent)', () => {
|
|
215
|
+
it('a job that fails then succeeds runs its success side-effect exactly once', async () => {
|
|
216
|
+
let attempts = 0;
|
|
217
|
+
let successes = 0;
|
|
218
|
+
const q = createQueue({
|
|
219
|
+
name: `d3-retry-${Date.now()}`,
|
|
220
|
+
onJob: async () => {
|
|
221
|
+
attempts += 1;
|
|
222
|
+
if (attempts < 3) throw new Error('transient');
|
|
223
|
+
successes += 1; // only on the successful attempt
|
|
224
|
+
},
|
|
225
|
+
options: { maxRetries: 5, retryDelay: 1 },
|
|
226
|
+
});
|
|
227
|
+
const ev = await withTenant(TENANT_A, async () => q.push({ job: { v: 1, instance_did: TENANT_A } }));
|
|
228
|
+
expect(await settle(ev)).toBe('finished');
|
|
229
|
+
expect(attempts).toBe(3); // 2 failures + 1 success, no infinite loop
|
|
230
|
+
expect(successes).toBe(1); // side-effect exactly once, not per attempt
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe('D3 data leak — tenant A and tenant B background jobs do not bleed', () => {
|
|
235
|
+
it('A and B jobs each run strictly under their own tenant scope', async () => {
|
|
236
|
+
const seen: Record<string, string> = {};
|
|
237
|
+
const qa = createQueue({
|
|
238
|
+
name: `d3-leak-a-${Date.now()}`,
|
|
239
|
+
onJob: async () => {
|
|
240
|
+
seen.a = getInstanceDid();
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
const qb = createQueue({
|
|
244
|
+
name: `d3-leak-b-${Date.now()}`,
|
|
245
|
+
onJob: async () => {
|
|
246
|
+
seen.b = getInstanceDid();
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
const ea = await withTenant(TENANT_A, async () => qa.push({ job: { instance_did: TENANT_A } }));
|
|
250
|
+
const eb = await withTenant(TENANT_B, async () => qb.push({ job: { instance_did: TENANT_B } }));
|
|
251
|
+
expect(await settle(ea)).toBe('finished');
|
|
252
|
+
expect(await settle(eb)).toBe('finished');
|
|
253
|
+
expect(seen.a).toBe(TENANT_A);
|
|
254
|
+
expect(seen.b).toBe(TENANT_B);
|
|
255
|
+
expect(seen.a).not.toBe(seen.b); // no cross-tenant bleed
|
|
256
|
+
});
|
|
257
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Deliberately violating fixture for the tenant query scanner self-test.
|
|
2
|
+
// NOT part of the CI scan scope (api/tests is excluded); the scanner is
|
|
3
|
+
// pointed at this file explicitly in scoped.spec.ts.
|
|
4
|
+
//
|
|
5
|
+
// W1′ Phase 5: a bare `Customer.findAll()` is now SAFE (Customer extends
|
|
6
|
+
// TenantModel — auto-scoped), so the violation the scanner must catch is a raw
|
|
7
|
+
// `.query(...)` on a tenant table with NO $instance_did bind (assertion ②).
|
|
8
|
+
import { sequelize } from '../../src/store/sequelize';
|
|
9
|
+
|
|
10
|
+
export function rawQueryViolation() {
|
|
11
|
+
// raw read on a tenant table (coupons) with no tenant bind — scanner flags it
|
|
12
|
+
return sequelize.query('SELECT * FROM coupons WHERE livemode = 1');
|
|
13
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Phase 12 (W2-4a) negative fixture — core code reading process.env / the CF env
|
|
2
|
+
// mirror directly, which the core-env scanner must reject (config must flow
|
|
3
|
+
// through the env/config boundary). Not in the CI scan scope; passed explicitly
|
|
4
|
+
// to the scanner in tests to prove the rule fires.
|
|
5
|
+
/* eslint-disable */
|
|
6
|
+
export function badConfigRead() {
|
|
7
|
+
const a = process.env.SOME_SECRET; // VIOLATION: direct process.env read in core
|
|
8
|
+
const b = (globalThis as any).__CF_ENV__?.APP_URL; // VIOLATION: CF env mirror read
|
|
9
|
+
return { a, b };
|
|
10
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Phase 10 (W2-2) negative fixture — a route reading the Host directly, which
|
|
2
|
+
// the tenant-scan rule must reject (tenant may only be resolved at the single
|
|
3
|
+
// middleware point). Not part of the CI scan scope; passed explicitly to the
|
|
4
|
+
// scanner in tests to prove the rule fires.
|
|
5
|
+
/* eslint-disable */
|
|
6
|
+
// Minimal req/res shapes — this is a STATIC-SCAN fixture (the tenant-query scanner
|
|
7
|
+
// reads the source text for Host reads); the handlers are never executed, so the
|
|
8
|
+
// express types are unnecessary (core is express-free post Phase 4).
|
|
9
|
+
type AnyReq = { headers: Record<string, any>; hostname?: string };
|
|
10
|
+
type AnyRes = { json: (body: any) => any };
|
|
11
|
+
|
|
12
|
+
export function badHandlerA(req: AnyReq, res: AnyRes) {
|
|
13
|
+
const host = req.headers.host; // VIOLATION: host read outside the tenant middleware
|
|
14
|
+
res.json({ host });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function badHandlerB(req: AnyReq, res: AnyRes) {
|
|
18
|
+
res.json({ h: req.hostname }); // VIOLATION: req.hostname read outside the tenant middleware
|
|
19
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// Two distinct tenant instanceDids used by all multi-tenant isolation tests.
|
|
2
|
+
// Built on top of the Phase 0 test injection helper (`withTenant`).
|
|
3
|
+
export const TENANT_A = 'did:abt:zTenantAAAAAAAAAAAAAAAAAAAAAAA';
|
|
4
|
+
export const TENANT_B = 'did:abt:zTenantBBBBBBBBBBBBBBBBBBBBBBB';
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { Hono } from 'hono';
|
|
5
|
+
import { Sequelize } from 'sequelize';
|
|
6
|
+
import { SequelizeStorage, Umzug } from 'umzug';
|
|
7
|
+
|
|
8
|
+
import { withTenant } from '../../src/libs/context';
|
|
9
|
+
import { TENANT_A, TENANT_B } from '../fixtures/tenants';
|
|
10
|
+
|
|
11
|
+
jest.mock('../../src/libs/logger', () => ({
|
|
12
|
+
__esModule: true,
|
|
13
|
+
default: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
// capture the tenant the verified-event handler runs under
|
|
17
|
+
const handleGooglePlayEvent = jest.fn().mockImplementation(async () => {
|
|
18
|
+
// eslint-disable-next-line global-require
|
|
19
|
+
const { getInstanceDid } = require('../../src/libs/context');
|
|
20
|
+
handlerTenants.push(getInstanceDid());
|
|
21
|
+
});
|
|
22
|
+
const handlerTenants: string[] = [];
|
|
23
|
+
jest.mock('../../src/integrations/google-play/handlers', () => ({
|
|
24
|
+
__esModule: true,
|
|
25
|
+
default: (...args: any[]) => handleGooglePlayEvent(...args),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
const handleAppStoreNotification = jest.fn().mockImplementation(async () => {
|
|
29
|
+
// eslint-disable-next-line global-require
|
|
30
|
+
const { getInstanceDid } = require('../../src/libs/context');
|
|
31
|
+
appStoreTenants.push(getInstanceDid());
|
|
32
|
+
});
|
|
33
|
+
const appStoreTenants: string[] = [];
|
|
34
|
+
jest.mock('../../src/integrations/app-store/handlers', () => ({
|
|
35
|
+
__esModule: true,
|
|
36
|
+
default: (...args: any[]) => handleAppStoreNotification(...args),
|
|
37
|
+
}));
|
|
38
|
+
// unverified routing peek: bundleId comes straight from our fake payload
|
|
39
|
+
jest.mock('../../src/integrations/app-store/notification-routing', () => ({
|
|
40
|
+
__esModule: true,
|
|
41
|
+
peekNotificationRouting: (signedPayload: string) => JSON.parse(signedPayload),
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
const STORE_DIR = path.join(__dirname, '../../src/store');
|
|
45
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'iap-tenant-'));
|
|
46
|
+
const sequelize = new Sequelize({ dialect: 'sqlite', storage: path.join(dir, 'test.db'), logging: false });
|
|
47
|
+
const umzug = new Umzug({
|
|
48
|
+
migrations: {
|
|
49
|
+
glob: ['migrations/*.ts', { cwd: STORE_DIR }],
|
|
50
|
+
resolve: ({ name, path: p, context }) => {
|
|
51
|
+
// eslint-disable-next-line import/no-dynamic-require, global-require
|
|
52
|
+
const migration = require(p!);
|
|
53
|
+
return {
|
|
54
|
+
name: name.replace(/\.ts$/, '.js'),
|
|
55
|
+
up: () => migration.up({ context }),
|
|
56
|
+
down: () => migration.down({ context }),
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
context: sequelize.getQueryInterface(),
|
|
61
|
+
storage: new SequelizeStorage({ sequelize }),
|
|
62
|
+
logger: undefined,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
let models: any;
|
|
66
|
+
let app: Hono;
|
|
67
|
+
|
|
68
|
+
const rtdnEnvelope = (packageName: string) => ({
|
|
69
|
+
message: {
|
|
70
|
+
messageId: `msg-${Math.random().toString(36).slice(2)}`,
|
|
71
|
+
data: Buffer.from(
|
|
72
|
+
JSON.stringify({
|
|
73
|
+
version: '1.0',
|
|
74
|
+
packageName,
|
|
75
|
+
eventTimeMillis: String(Date.now()),
|
|
76
|
+
subscriptionNotification: {
|
|
77
|
+
version: '1.0',
|
|
78
|
+
notificationType: 4,
|
|
79
|
+
purchaseToken: 'token-x',
|
|
80
|
+
subscriptionId: 'sku-x',
|
|
81
|
+
},
|
|
82
|
+
})
|
|
83
|
+
).toString('base64'),
|
|
84
|
+
},
|
|
85
|
+
subscription: 'projects/x/subscriptions/y',
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const postWebhook = async (body: any): Promise<{ status: number; json: any }> => {
|
|
89
|
+
const res = await app.fetch(
|
|
90
|
+
new Request('http://app.local/api/integrations/google-play/webhook', {
|
|
91
|
+
method: 'POST',
|
|
92
|
+
headers: { 'content-type': 'application/json' },
|
|
93
|
+
body: JSON.stringify(body),
|
|
94
|
+
})
|
|
95
|
+
);
|
|
96
|
+
return { status: res.status, json: JSON.parse((await res.text()) || '{}') };
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const seedMethod = (tenant: string, packageName: string) =>
|
|
100
|
+
withTenant(tenant, () =>
|
|
101
|
+
models.PaymentMethod.create({
|
|
102
|
+
livemode: false,
|
|
103
|
+
active: true,
|
|
104
|
+
type: 'google_play',
|
|
105
|
+
instance_did: tenant,
|
|
106
|
+
confirmation: { type: 'callback' },
|
|
107
|
+
features: { recurring: true, refund: true, dispute: false },
|
|
108
|
+
settings: models.PaymentMethod.encryptSettings({
|
|
109
|
+
google_play: { package_name: packageName, service_account_json: '{"client_email":"x","private_key":"y"}' },
|
|
110
|
+
}),
|
|
111
|
+
})
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
beforeAll(async () => {
|
|
115
|
+
await umzug.up();
|
|
116
|
+
// eslint-disable-next-line global-require
|
|
117
|
+
models = require('../../src/store/models');
|
|
118
|
+
models.initialize(sequelize);
|
|
119
|
+
// eslint-disable-next-line global-require
|
|
120
|
+
const googlePlay = require('../../src/routes/hono/integrations/google-play').default;
|
|
121
|
+
// eslint-disable-next-line global-require
|
|
122
|
+
const appStore = require('../../src/routes/hono/integrations/app-store').default;
|
|
123
|
+
// eslint-disable-next-line global-require
|
|
124
|
+
const { mountResourceGroup } = require('../../src/middlewares/hono/resource-mount');
|
|
125
|
+
// Mount exactly as production does (app-shell pipeline → sanitizedBody/livemode
|
|
126
|
+
// populated, csrf skipped without a cookie). Drive via app.fetch — single-mode
|
|
127
|
+
// test env, so contextMiddleware resolves the default tenant without a Host.
|
|
128
|
+
app = new Hono();
|
|
129
|
+
mountResourceGroup(app, '/api/integrations/google-play', googlePlay);
|
|
130
|
+
mountResourceGroup(app, '/api/integrations/app-store', appStore);
|
|
131
|
+
}, 120000);
|
|
132
|
+
|
|
133
|
+
afterAll(async () => {
|
|
134
|
+
await sequelize.close();
|
|
135
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
beforeEach(async () => {
|
|
139
|
+
// restoreMocks=true resets spies after each test — re-install per test.
|
|
140
|
+
// the App Store client is exercised elsewhere; here it must only verify
|
|
141
|
+
jest
|
|
142
|
+
.spyOn(models.PaymentMethod.prototype, 'getAppStoreClient')
|
|
143
|
+
.mockReturnValue({ verifyNotificationPayload: async (p: string) => JSON.parse(p) } as any);
|
|
144
|
+
handleGooglePlayEvent.mockClear();
|
|
145
|
+
handleAppStoreNotification.mockClear();
|
|
146
|
+
handlerTenants.length = 0;
|
|
147
|
+
appStoreTenants.length = 0;
|
|
148
|
+
await sequelize.query('DELETE FROM payment_methods');
|
|
149
|
+
await sequelize.query('DELETE FROM prices');
|
|
150
|
+
await sequelize.query('DELETE FROM products');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const seedAppStoreMethod = (tenant: string, bundleId: string) =>
|
|
154
|
+
withTenant(tenant, () =>
|
|
155
|
+
models.PaymentMethod.create({
|
|
156
|
+
livemode: false,
|
|
157
|
+
active: true,
|
|
158
|
+
type: 'app_store',
|
|
159
|
+
instance_did: tenant,
|
|
160
|
+
confirmation: { type: 'callback' },
|
|
161
|
+
features: { recurring: true, refund: false, dispute: false },
|
|
162
|
+
settings: models.PaymentMethod.encryptSettings({
|
|
163
|
+
app_store: { bundle_id: bundleId, environment: 'production' },
|
|
164
|
+
}),
|
|
165
|
+
})
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const postAppStoreWebhook = async (routing: {
|
|
169
|
+
bundleId: string;
|
|
170
|
+
environment?: string;
|
|
171
|
+
}): Promise<{ status: number; json: any }> => {
|
|
172
|
+
const res = await app.fetch(
|
|
173
|
+
new Request('http://app.local/api/integrations/app-store/webhook', {
|
|
174
|
+
method: 'POST',
|
|
175
|
+
headers: { 'content-type': 'application/json' },
|
|
176
|
+
body: JSON.stringify({ signedPayload: JSON.stringify(routing) }),
|
|
177
|
+
})
|
|
178
|
+
);
|
|
179
|
+
return { status: res.status, json: JSON.parse((await res.text()) || '{}') };
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
describe('IAP channel-identifier tenant reverse lookup (phase 6)', () => {
|
|
183
|
+
it('happy path: RTDN routes to the tenant that registered the package_name', async () => {
|
|
184
|
+
await seedMethod(TENANT_A, 'com.tenant.a');
|
|
185
|
+
await seedMethod(TENANT_B, 'com.tenant.b');
|
|
186
|
+
|
|
187
|
+
const res = await postWebhook(rtdnEnvelope('com.tenant.b'));
|
|
188
|
+
expect(res.status).toBe(200);
|
|
189
|
+
expect(res.json).toEqual({ received: true });
|
|
190
|
+
expect(handleGooglePlayEvent).toHaveBeenCalledTimes(1);
|
|
191
|
+
expect(handlerTenants).toEqual([TENANT_B]);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('bad input: unregistered package_name is acked-skipped without touching any tenant', async () => {
|
|
195
|
+
await seedMethod(TENANT_A, 'com.tenant.a');
|
|
196
|
+
const res = await postWebhook(rtdnEnvelope('com.unknown.app'));
|
|
197
|
+
expect(res.status).toBe(200);
|
|
198
|
+
expect(res.json).toEqual({ skipped: true });
|
|
199
|
+
expect(handleGooglePlayEvent).not.toHaveBeenCalled();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('security: ambiguous registration (same package under two tenants) is refused', async () => {
|
|
203
|
+
await seedMethod(TENANT_A, 'com.shared.app');
|
|
204
|
+
await seedMethod(TENANT_B, 'com.shared.app');
|
|
205
|
+
const res = await postWebhook(rtdnEnvelope('com.shared.app'));
|
|
206
|
+
expect(res.status).toBe(200);
|
|
207
|
+
expect(res.json.reason).toContain('ambiguous');
|
|
208
|
+
expect(handleGooglePlayEvent).not.toHaveBeenCalled();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('app-store: JWS bundleId routes to the registering tenant; ambiguity refused', async () => {
|
|
212
|
+
await seedAppStoreMethod(TENANT_A, 'com.ios.a');
|
|
213
|
+
await seedAppStoreMethod(TENANT_B, 'com.ios.b');
|
|
214
|
+
|
|
215
|
+
const res = await postAppStoreWebhook({ bundleId: 'com.ios.b', environment: 'production' });
|
|
216
|
+
expect(res.status).toBe(200);
|
|
217
|
+
expect(res.json).toEqual({ received: true });
|
|
218
|
+
expect(appStoreTenants).toEqual([TENANT_B]);
|
|
219
|
+
|
|
220
|
+
// ambiguity: register the SAME bundle under another tenant -> refused
|
|
221
|
+
await seedAppStoreMethod(TENANT_A, 'com.ios.b');
|
|
222
|
+
const dup = await postAppStoreWebhook({ bundleId: 'com.ios.b', environment: 'production' });
|
|
223
|
+
expect(dup.json.reason).toContain('ambiguous');
|
|
224
|
+
expect(appStoreTenants).toHaveLength(1); // no second delivery
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('data damage: two tenants with the same SKU resolve to their own Price via bundle scoping', async () => {
|
|
228
|
+
const seedPrice = (tenant: string, bundleId: string, marker: string) =>
|
|
229
|
+
withTenant(tenant, async () => {
|
|
230
|
+
const product = await models.Product.create({
|
|
231
|
+
livemode: false,
|
|
232
|
+
active: true,
|
|
233
|
+
instance_did: tenant,
|
|
234
|
+
name: marker,
|
|
235
|
+
type: 'service',
|
|
236
|
+
});
|
|
237
|
+
return models.Price.create({
|
|
238
|
+
livemode: false,
|
|
239
|
+
active: true,
|
|
240
|
+
instance_did: tenant,
|
|
241
|
+
product_id: product.id,
|
|
242
|
+
type: 'recurring',
|
|
243
|
+
billing_scheme: 'per_unit',
|
|
244
|
+
unit_amount: '100',
|
|
245
|
+
currency_options: [],
|
|
246
|
+
metadata: { app_store_product_id: 'sku.shared', bundle_id: bundleId },
|
|
247
|
+
nickname: marker,
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
const priceA = await seedPrice(TENANT_A, 'com.ios.a', 'price-a');
|
|
251
|
+
const priceB = await seedPrice(TENANT_B, 'com.ios.b', 'price-b');
|
|
252
|
+
|
|
253
|
+
// the (sku, bundle_id) lookup used by the IAP handlers. The handler first
|
|
254
|
+
// reverse-resolves the tenant from the registering PaymentMethod, then runs
|
|
255
|
+
// the price lookup under withTenant(tenant) — TenantModel scopes it to that
|
|
256
|
+
// tenant's row even though both share the SKU.
|
|
257
|
+
const foundA: any = await withTenant(TENANT_A, () =>
|
|
258
|
+
models.Price.findOne({
|
|
259
|
+
where: { 'metadata.app_store_product_id': 'sku.shared', 'metadata.bundle_id': 'com.ios.a' } as any,
|
|
260
|
+
})
|
|
261
|
+
);
|
|
262
|
+
const foundB: any = await withTenant(TENANT_B, () =>
|
|
263
|
+
models.Price.findOne({
|
|
264
|
+
where: { 'metadata.app_store_product_id': 'sku.shared', 'metadata.bundle_id': 'com.ios.b' } as any,
|
|
265
|
+
})
|
|
266
|
+
);
|
|
267
|
+
expect(foundA.id).toBe(priceA.id);
|
|
268
|
+
expect(foundA.instance_did).toBe(TENANT_A);
|
|
269
|
+
expect(foundB.id).toBe(priceB.id);
|
|
270
|
+
expect(foundB.instance_did).toBe(TENANT_B);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('data leak: a forged notification can only ever land in the registering tenant', async () => {
|
|
274
|
+
// even if an attacker controls the payload entirely, the tenant is chosen
|
|
275
|
+
// by the server-side registration, never by payload contents
|
|
276
|
+
await seedMethod(TENANT_A, 'com.tenant.a');
|
|
277
|
+
const res = await postWebhook({
|
|
278
|
+
...rtdnEnvelope('com.tenant.a'),
|
|
279
|
+
attacker: { wants: TENANT_B },
|
|
280
|
+
});
|
|
281
|
+
expect(res.status).toBe(200);
|
|
282
|
+
expect(handlerTenants).toEqual([TENANT_A]);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
@@ -124,6 +124,32 @@ describe('archive/query', () => {
|
|
|
124
124
|
expect(mockClose).toHaveBeenCalled();
|
|
125
125
|
});
|
|
126
126
|
|
|
127
|
+
it('洞 G: the data SELECT on a tenant table is instance_did-guarded', async () => {
|
|
128
|
+
const mockClose = jest.fn();
|
|
129
|
+
let dataSql = '';
|
|
130
|
+
let dataOpts: any = null;
|
|
131
|
+
const mockQuery = jest.fn().mockImplementation((sql: string, opts: any) => {
|
|
132
|
+
if (sql.includes('sqlite_master')) return [[{ name: 'invoices' }]];
|
|
133
|
+
dataSql = sql;
|
|
134
|
+
dataOpts = opts;
|
|
135
|
+
return [];
|
|
136
|
+
});
|
|
137
|
+
mockListArchiveFiles.mockReturnValue(['/tmp/archive-2024.db']);
|
|
138
|
+
mockOpenArchiveSequelize.mockReturnValue({ query: mockQuery, close: mockClose } as any);
|
|
139
|
+
mockArchiveMetadataFindAll.mockResolvedValue([]);
|
|
140
|
+
|
|
141
|
+
await queryArchive({
|
|
142
|
+
table: 'invoices',
|
|
143
|
+
from: Math.floor(new Date('2024-01-01').getTime() / 1000),
|
|
144
|
+
page: 1,
|
|
145
|
+
limit: 10,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// the archived tenant-table read carries an instance_did predicate + bind
|
|
149
|
+
expect(dataSql).toMatch(/instance_did/);
|
|
150
|
+
expect(dataOpts?.replacements?.instance_did).toBeTruthy();
|
|
151
|
+
});
|
|
152
|
+
|
|
127
153
|
it('should skip archive files that do not have the requested table', async () => {
|
|
128
154
|
const mockClose = jest.fn();
|
|
129
155
|
// Return empty array for sqlite_master query (table doesn't exist)
|