payment-kit 1.29.1 → 1.29.3
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 +47 -0
- package/api/src/crons/base.ts +3 -3
- package/api/src/crons/currency.ts +1 -1
- package/api/src/crons/index.ts +41 -37
- 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/crons/tenant-fanout.ts +82 -0
- package/api/src/host-node/did-connect-runtime-node.ts +33 -0
- package/api/src/host-node/serve-static-arc.ts +68 -0
- package/api/src/host-node/serve-static.ts +41 -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 +247 -47
- package/api/src/libs/context.ts +89 -1
- package/api/src/libs/currency.ts +2 -2
- package/api/src/libs/dayjs.ts +8 -2
- package/api/src/libs/did-connect/runtime-did-connect-js.ts +88 -0
- package/api/src/libs/did-connect/tenant-identity.ts +221 -0
- 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 +142 -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 +60 -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 +271 -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 +80 -0
- package/api/src/middlewares/hono/csrf.ts +83 -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 +209 -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 +38 -21
- 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 +41 -11
- 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 +64 -37
- package/api/src/queues/payout.ts +37 -21
- package/api/src/queues/refund.ts +36 -18
- 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} +199 -224
- 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} +98 -83
- 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 +814 -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 +82 -23
- 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/bootstrap/bootstrap.spec.ts +162 -0
- package/api/tests/crons/tenant-fanout.spec.ts +158 -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/did-connect-runtime-js.spec.ts +98 -0
- package/api/tests/libs/did-connect-tenant-identity.spec.ts +159 -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/service-host.spec.ts +37 -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 +292 -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/service/didconnect-storage-slot.spec.ts +60 -0
- package/api/tests/service/fail-closed-http.spec.ts +79 -0
- package/api/tests/service/static-arc-handler.spec.ts +101 -0
- package/api/tests/service/static-externalized.spec.ts +48 -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/MIGRATION-RUNBOOK.md +3 -8
- package/cloudflare/README.md +34 -27
- package/cloudflare/STAGING-MIGRATION-GUIDE.md +3 -15
- package/cloudflare/build.ts +33 -13
- package/cloudflare/cf-adapter.ts +419 -0
- package/cloudflare/did-connect-runtime.ts +96 -0
- package/cloudflare/did-connect-token-storage.ts +151 -0
- package/cloudflare/esbuild-cf-config.cjs +407 -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 +33 -403
- package/cloudflare/scripts/cf-package-import-probe.mjs +90 -0
- package/cloudflare/scripts/didconnect-mock-smoke.mjs +140 -0
- 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/wallet-authenticator.ts +16 -1
- package/cloudflare/shims/blocklet-sdk/wallet-handler.ts +18 -3
- 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/xss.ts +8 -0
- package/cloudflare/tenant-middleware.ts +36 -0
- package/cloudflare/tests/cf-adapter.spec.ts +244 -0
- package/cloudflare/tests/did-connect-token-storage.spec.ts +105 -0
- package/cloudflare/tests/tenant-middleware.spec.ts +160 -0
- package/cloudflare/tests/worker-handler-gate.spec.ts +69 -0
- package/cloudflare/vite.config.ts +53 -45
- package/cloudflare/worker.ts +261 -448
- package/cloudflare/wrangler.json +0 -6
- package/cloudflare/wrangler.jsonc +0 -6
- package/cloudflare/wrangler.local-e2e.jsonc +25 -0
- package/cloudflare/wrangler.staging.json +0 -6
- package/jest.config.js +3 -1
- package/package.json +33 -38
- package/scripts/bootstrap-inject.ts +166 -0
- 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/app.tsx +2 -1
- package/src/env.d.ts +13 -1
- package/src/libs/service-host.ts +13 -0
- package/tsconfig.json +1 -1
- package/vite.arc.config.ts +159 -0
- 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/did-connect-auth.ts +0 -527
- 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,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)
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { Sequelize } from 'sequelize';
|
|
5
|
+
import { SequelizeStorage, Umzug } from 'umzug';
|
|
6
|
+
|
|
7
|
+
import { withTenant } from '../../src/libs/context';
|
|
8
|
+
import { TENANT_CONTEXT_MISSING, TENANT_MISMATCH } from '../../src/libs/tenant';
|
|
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
|
+
const STORE_DIR = path.join(__dirname, '../../src/store');
|
|
17
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'audit-tenant-'));
|
|
18
|
+
const sequelize = new Sequelize({ dialect: 'sqlite', storage: path.join(dir, 'test.db'), logging: false });
|
|
19
|
+
const umzug = new Umzug({
|
|
20
|
+
migrations: {
|
|
21
|
+
glob: ['migrations/*.ts', { cwd: STORE_DIR }],
|
|
22
|
+
resolve: ({ name, path: p, context }) => {
|
|
23
|
+
// eslint-disable-next-line import/no-dynamic-require, global-require
|
|
24
|
+
const migration = require(p!);
|
|
25
|
+
return {
|
|
26
|
+
name: name.replace(/\.ts$/, '.js'),
|
|
27
|
+
up: () => migration.up({ context }),
|
|
28
|
+
down: () => migration.down({ context }),
|
|
29
|
+
};
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
context: sequelize.getQueryInterface(),
|
|
33
|
+
storage: new SequelizeStorage({ sequelize }),
|
|
34
|
+
logger: undefined,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
let audit: typeof import('../../src/libs/audit');
|
|
38
|
+
let logger: any;
|
|
39
|
+
|
|
40
|
+
beforeAll(async () => {
|
|
41
|
+
await umzug.up();
|
|
42
|
+
// eslint-disable-next-line global-require
|
|
43
|
+
const models = require('../../src/store/models');
|
|
44
|
+
models.initialize(sequelize);
|
|
45
|
+
// eslint-disable-next-line global-require
|
|
46
|
+
audit = require('../../src/libs/audit');
|
|
47
|
+
// eslint-disable-next-line global-require
|
|
48
|
+
logger = require('../../src/libs/logger').default;
|
|
49
|
+
}, 120000);
|
|
50
|
+
|
|
51
|
+
afterAll(async () => {
|
|
52
|
+
await sequelize.close();
|
|
53
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
beforeEach(async () => {
|
|
57
|
+
await sequelize.query('DELETE FROM events');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const fakeModel = (tenant: string | null, extra: Record<string, any> = {}) => ({
|
|
61
|
+
id: 'obj_1',
|
|
62
|
+
livemode: false,
|
|
63
|
+
instance_did: tenant,
|
|
64
|
+
dataValues: { id: 'obj_1', instance_did: tenant, ...extra },
|
|
65
|
+
_previousDataValues: {},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('audit tenant resolution (phase 4)', () => {
|
|
69
|
+
describe('happy path', () => {
|
|
70
|
+
it('takes the tenant from the model row', async () => {
|
|
71
|
+
await audit.createEvent('Customer', 'customer.created', fakeModel(TENANT_A));
|
|
72
|
+
const [rows] = await sequelize.query('SELECT type, instance_did FROM events');
|
|
73
|
+
expect(rows).toEqual([{ type: 'customer.created', instance_did: TENANT_A }]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('falls back to the tenant context when the model has none', async () => {
|
|
77
|
+
await withTenant(TENANT_B, () => audit.createEvent('Customer', 'customer.created', fakeModel(null)));
|
|
78
|
+
const [rows] = await sequelize.query('SELECT instance_did FROM events');
|
|
79
|
+
expect(rows).toEqual([{ instance_did: TENANT_B }]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('createFlexibleEvent (system source) uses the tenant context', async () => {
|
|
83
|
+
const event: any = await withTenant(TENANT_A, () =>
|
|
84
|
+
audit.createFlexibleEvent('payout.created', 'payout', 'po_1', { ok: true })
|
|
85
|
+
);
|
|
86
|
+
expect(event.instance_did).toBe(TENANT_A);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('bad input: both sources missing', () => {
|
|
91
|
+
it('rejects in multi mode and writes no event row', async () => {
|
|
92
|
+
process.env.PAYMENT_TENANT_MODE = 'multi';
|
|
93
|
+
try {
|
|
94
|
+
await expect(audit.createEvent('Customer', 'customer.created', fakeModel(null))).rejects.toMatchObject({
|
|
95
|
+
code: TENANT_CONTEXT_MISSING,
|
|
96
|
+
});
|
|
97
|
+
} finally {
|
|
98
|
+
delete process.env.PAYMENT_TENANT_MODE;
|
|
99
|
+
}
|
|
100
|
+
const [rows] = await sequelize.query('SELECT COUNT(*) AS n FROM events');
|
|
101
|
+
expect((rows as any[])[0].n).toBe(0);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('security: contradictory sources', () => {
|
|
106
|
+
it('rejects when model tenant and context tenant disagree', async () => {
|
|
107
|
+
await withTenant(TENANT_A, async () => {
|
|
108
|
+
await expect(audit.createEvent('Customer', 'customer.created', fakeModel(TENANT_B))).rejects.toMatchObject({
|
|
109
|
+
code: TENANT_MISMATCH,
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
const [rows] = await sequelize.query('SELECT COUNT(*) AS n FROM events');
|
|
113
|
+
expect((rows as any[])[0].n).toBe(0);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('data loss: rejection never blocks the business write', () => {
|
|
118
|
+
it('reportAuditFailure is fire-and-forget with a dedicated code', () => {
|
|
119
|
+
const err = Object.assign(new Error('conflict'), { code: TENANT_MISMATCH });
|
|
120
|
+
expect(() => audit.reportAuditFailure(err)).not.toThrow();
|
|
121
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
122
|
+
'[audit] event creation failed',
|
|
123
|
+
expect.objectContaining({ code: TENANT_MISMATCH })
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
audit.reportAuditFailure(new Error('boom'));
|
|
127
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
128
|
+
'[audit] event creation failed',
|
|
129
|
+
expect.objectContaining({ code: 'EVENT_CREATE_FAILED' })
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('data leak: status/custom event paths carry the tenant too', () => {
|
|
135
|
+
it('createStatusEvent stamps the model tenant', async () => {
|
|
136
|
+
const model = fakeModel(TENANT_A, { status: 'active' });
|
|
137
|
+
await audit.createStatusEvent('Subscription', 'customer.subscription', { active: 'activated' }, model, {
|
|
138
|
+
fields: ['status'],
|
|
139
|
+
});
|
|
140
|
+
const [rows] = await sequelize.query('SELECT instance_did FROM events');
|
|
141
|
+
expect(rows).toEqual([{ instance_did: TENANT_A }]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('createCustomEvent stamps the model tenant', async () => {
|
|
145
|
+
const model = fakeModel(TENANT_B, { status: 'canceled' });
|
|
146
|
+
await audit.createCustomEvent('Subscription', 'customer.subscription', () => 'deleted', model, {
|
|
147
|
+
fields: ['status'],
|
|
148
|
+
});
|
|
149
|
+
const [rows] = await sequelize.query('SELECT type, instance_did FROM events');
|
|
150
|
+
expect(rows).toEqual([{ type: 'customer.subscription.deleted', instance_did: TENANT_B }]);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import {
|
|
2
|
+
TENANT_CONTEXT_MISSING,
|
|
3
|
+
TenantError,
|
|
4
|
+
context,
|
|
5
|
+
getDefaultInstanceDid,
|
|
6
|
+
getInstanceDid,
|
|
7
|
+
getTenantMode,
|
|
8
|
+
withTenant,
|
|
9
|
+
} from '../../src/libs/context';
|
|
10
|
+
import { TENANT_A, TENANT_B } from '../fixtures/tenants';
|
|
11
|
+
|
|
12
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
13
|
+
|
|
14
|
+
describe('libs/context tenant infrastructure', () => {
|
|
15
|
+
const originalMode = process.env.PAYMENT_TENANT_MODE;
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
if (originalMode === undefined) {
|
|
19
|
+
delete process.env.PAYMENT_TENANT_MODE;
|
|
20
|
+
} else {
|
|
21
|
+
process.env.PAYMENT_TENANT_MODE = originalMode;
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const asMulti = () => {
|
|
26
|
+
process.env.PAYMENT_TENANT_MODE = 'multi';
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
describe('tenant mode', () => {
|
|
30
|
+
it('defaults to single mode', () => {
|
|
31
|
+
delete process.env.PAYMENT_TENANT_MODE;
|
|
32
|
+
expect(getTenantMode()).toBe('single');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('reads multi mode from env', () => {
|
|
36
|
+
asMulti();
|
|
37
|
+
expect(getTenantMode()).toBe('multi');
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('happy path', () => {
|
|
42
|
+
it('returns the tenant inside withTenant and across await boundaries', async () => {
|
|
43
|
+
await withTenant(TENANT_A, async () => {
|
|
44
|
+
expect(getInstanceDid()).toBe(TENANT_A);
|
|
45
|
+
await sleep(5);
|
|
46
|
+
expect(getInstanceDid()).toBe(TENANT_A);
|
|
47
|
+
await Promise.resolve().then(() => {
|
|
48
|
+
expect(getInstanceDid()).toBe(TENANT_A);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('exposes instanceDid via context.getContext()', async () => {
|
|
54
|
+
await withTenant(TENANT_A, async () => {
|
|
55
|
+
expect(context.getContext().instanceDid).toBe(TENANT_A);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('returns the value produced by fn', async () => {
|
|
60
|
+
const result = await withTenant(TENANT_A, async () => 42);
|
|
61
|
+
expect(result).toBe(42);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('keeps tenant when context.run is nested inside withTenant', async () => {
|
|
65
|
+
await withTenant(TENANT_A, () =>
|
|
66
|
+
context.run({ requestedBy: 'user-x' }, async () => {
|
|
67
|
+
expect(getInstanceDid()).toBe(TENANT_A);
|
|
68
|
+
expect(context.getRequestedBy()).toBe('user-x');
|
|
69
|
+
})
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('keeps tenant when withTenant is nested inside context.run', async () => {
|
|
74
|
+
await context.run({ requestedBy: 'user-x' }, () =>
|
|
75
|
+
withTenant(TENANT_A, async () => {
|
|
76
|
+
expect(getInstanceDid()).toBe(TENANT_A);
|
|
77
|
+
expect(context.getRequestedBy()).toBe('user-x');
|
|
78
|
+
})
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('bad input', () => {
|
|
84
|
+
it.each([['' as any], [' ' as any], [null as any], [undefined as any], [123 as any], ['a b' as any]])(
|
|
85
|
+
'rejects invalid instanceDid %p',
|
|
86
|
+
async (bad) => {
|
|
87
|
+
await expect(withTenant(bad, async () => 'never')).rejects.toMatchObject({
|
|
88
|
+
code: TENANT_CONTEXT_MISSING,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
it('throws TENANT_CONTEXT_MISSING in multi mode without context', () => {
|
|
94
|
+
asMulti();
|
|
95
|
+
try {
|
|
96
|
+
getInstanceDid();
|
|
97
|
+
throw new Error('expected getInstanceDid to throw');
|
|
98
|
+
} catch (err: any) {
|
|
99
|
+
expect(err).toBeInstanceOf(TenantError);
|
|
100
|
+
expect(err.code).toBe(TENANT_CONTEXT_MISSING);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('security: concurrent isolation', () => {
|
|
106
|
+
it('does not leak tenant between interleaved concurrent withTenant scopes', async () => {
|
|
107
|
+
const observed: Record<string, string[]> = { a: [], b: [] };
|
|
108
|
+
await Promise.all([
|
|
109
|
+
withTenant(TENANT_A, async () => {
|
|
110
|
+
observed.a!.push(getInstanceDid());
|
|
111
|
+
await sleep(10);
|
|
112
|
+
observed.a!.push(getInstanceDid());
|
|
113
|
+
await sleep(20);
|
|
114
|
+
observed.a!.push(getInstanceDid());
|
|
115
|
+
}),
|
|
116
|
+
withTenant(TENANT_B, async () => {
|
|
117
|
+
observed.b!.push(getInstanceDid());
|
|
118
|
+
await sleep(15);
|
|
119
|
+
observed.b!.push(getInstanceDid());
|
|
120
|
+
await sleep(5);
|
|
121
|
+
observed.b!.push(getInstanceDid());
|
|
122
|
+
}),
|
|
123
|
+
]);
|
|
124
|
+
expect(observed.a).toEqual([TENANT_A, TENANT_A, TENANT_A]);
|
|
125
|
+
expect(observed.b).toEqual([TENANT_B, TENANT_B, TENANT_B]);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('data loss: context survives async scheduling primitives', () => {
|
|
130
|
+
it('keeps tenant across setTimeout', async () => {
|
|
131
|
+
await withTenant(TENANT_A, async () => {
|
|
132
|
+
const seen = await new Promise((resolve) => {
|
|
133
|
+
setTimeout(() => resolve(getInstanceDid()), 5);
|
|
134
|
+
});
|
|
135
|
+
expect(seen).toBe(TENANT_A);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('keeps tenant across queueMicrotask', async () => {
|
|
140
|
+
await withTenant(TENANT_A, async () => {
|
|
141
|
+
const seen = await new Promise((resolve) => {
|
|
142
|
+
queueMicrotask(() => resolve(getInstanceDid()));
|
|
143
|
+
});
|
|
144
|
+
expect(seen).toBe(TENANT_A);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('keeps tenant inside event emitter callbacks registered within the scope', async () => {
|
|
149
|
+
// eslint-disable-next-line global-require
|
|
150
|
+
const { EventEmitter } = require('events');
|
|
151
|
+
const emitter = new EventEmitter();
|
|
152
|
+
await withTenant(TENANT_A, async () => {
|
|
153
|
+
const seen = await new Promise((resolve) => {
|
|
154
|
+
emitter.on('ping', () => resolve(getInstanceDid()));
|
|
155
|
+
emitter.emit('ping');
|
|
156
|
+
});
|
|
157
|
+
expect(seen).toBe(TENANT_A);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('data damage: nesting and immutability', () => {
|
|
163
|
+
it('restores outer tenant after a nested withTenant exits', async () => {
|
|
164
|
+
await withTenant(TENANT_A, async () => {
|
|
165
|
+
expect(getInstanceDid()).toBe(TENANT_A);
|
|
166
|
+
await withTenant(TENANT_B, async () => {
|
|
167
|
+
expect(getInstanceDid()).toBe(TENANT_B);
|
|
168
|
+
});
|
|
169
|
+
expect(getInstanceDid()).toBe(TENANT_A);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('prevents fn from mutating the stored context to affect sibling calls', async () => {
|
|
174
|
+
await withTenant(TENANT_A, async () => {
|
|
175
|
+
const ctx = context.getContext() as any;
|
|
176
|
+
try {
|
|
177
|
+
ctx.instanceDid = TENANT_B;
|
|
178
|
+
} catch {
|
|
179
|
+
// frozen object throws in strict mode — either way the mutation must not stick
|
|
180
|
+
}
|
|
181
|
+
expect(getInstanceDid()).toBe(TENANT_A);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe('data leak: single mode default fill', () => {
|
|
187
|
+
it('falls back to the configured app DID in single mode', () => {
|
|
188
|
+
delete process.env.PAYMENT_TENANT_MODE;
|
|
189
|
+
// jest globalSetup configures BLOCKLET_APP_PID with the test wallet address
|
|
190
|
+
expect(getInstanceDid()).toBe(getDefaultInstanceDid());
|
|
191
|
+
expect(getDefaultInstanceDid()).toBe(process.env.BLOCKLET_APP_PID);
|
|
192
|
+
expect(getDefaultInstanceDid()).not.toBe(TENANT_A);
|
|
193
|
+
expect(getDefaultInstanceDid()).not.toBe(TENANT_B);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('never leaks a tenant injected by another scope into the default', async () => {
|
|
197
|
+
await withTenant(TENANT_A, async () => {
|
|
198
|
+
expect(getInstanceDid()).toBe(TENANT_A);
|
|
199
|
+
});
|
|
200
|
+
// outside any scope, single mode returns the app DID again
|
|
201
|
+
expect(getInstanceDid()).toBe(getDefaultInstanceDid());
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
});
|