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,161 @@
|
|
|
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 { TENANT_TABLES } from '../../src/store/tenant-tables';
|
|
8
|
+
|
|
9
|
+
const STORE_DIR = path.join(__dirname, '../../src/store');
|
|
10
|
+
const TENANT_MIGRATION = '20260610-tenant-columns';
|
|
11
|
+
// pin the chain to phase 1: phase 2 (tenant backfill) intentionally rebuilds
|
|
12
|
+
// some tables with NOT NULL + composite uniques, which this spec must not see
|
|
13
|
+
const upToPhase1 = (umzug: Umzug<any>) => umzug.up({ to: `${TENANT_MIGRATION}.js` } as any);
|
|
14
|
+
|
|
15
|
+
function createHarness(storagePath: string) {
|
|
16
|
+
const sequelize = new Sequelize({ dialect: 'sqlite', storage: storagePath, logging: false });
|
|
17
|
+
const umzug = new Umzug({
|
|
18
|
+
migrations: {
|
|
19
|
+
glob: ['migrations/*.ts', { cwd: STORE_DIR }],
|
|
20
|
+
resolve: ({ name, path: migrationPath, context }) => {
|
|
21
|
+
// eslint-disable-next-line import/no-dynamic-require, global-require
|
|
22
|
+
const migration = require(migrationPath!);
|
|
23
|
+
return {
|
|
24
|
+
name: name.replace(/\.ts$/, '.js'),
|
|
25
|
+
up: () => migration.up({ context }),
|
|
26
|
+
down: () => migration.down({ context }),
|
|
27
|
+
};
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
context: sequelize.getQueryInterface(),
|
|
31
|
+
storage: new SequelizeStorage({ sequelize }),
|
|
32
|
+
logger: undefined,
|
|
33
|
+
});
|
|
34
|
+
return { sequelize, umzug };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function tableColumns(sequelize: Sequelize, table: string): Promise<Record<string, any>[]> {
|
|
38
|
+
const [rows] = await sequelize.query(`PRAGMA table_info(${table})`);
|
|
39
|
+
return rows as Record<string, any>[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('tenant columns migration (phase 1)', () => {
|
|
43
|
+
let dir: string;
|
|
44
|
+
let dbPath: string;
|
|
45
|
+
let harness: ReturnType<typeof createHarness>;
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tenant-columns-'));
|
|
49
|
+
dbPath = path.join(dir, 'test.db');
|
|
50
|
+
harness = createHarness(dbPath);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterEach(async () => {
|
|
54
|
+
await harness.sequelize.close();
|
|
55
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('happy path', () => {
|
|
59
|
+
it('adds a nullable instance_did column to all 38 tenant tables', async () => {
|
|
60
|
+
await upToPhase1(harness.umzug);
|
|
61
|
+
for (const table of TENANT_TABLES) {
|
|
62
|
+
// eslint-disable-next-line no-await-in-loop
|
|
63
|
+
const columns = await tableColumns(harness.sequelize, table);
|
|
64
|
+
const column = columns.find((c) => c.name === 'instance_did');
|
|
65
|
+
expect({ table, found: Boolean(column) }).toEqual({ table, found: true });
|
|
66
|
+
expect({ table, notnull: column!.notnull }).toEqual({ table, notnull: 0 });
|
|
67
|
+
expect({ table, dflt: column!.dflt_value ?? null }).toEqual({ table, dflt: null });
|
|
68
|
+
}
|
|
69
|
+
}, 60000);
|
|
70
|
+
|
|
71
|
+
// "model layer exposes instance_did" lives in tenant-columns-model.spec.ts:
|
|
72
|
+
// initializing the model singletons here would mutate GENESIS_ATTRIBUTES
|
|
73
|
+
// (associations add FK clauses) and corrupt the migration harness below.
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('bad input: re-running up is idempotent', () => {
|
|
77
|
+
it('skips existing columns without error and without duplicating them', async () => {
|
|
78
|
+
await upToPhase1(harness.umzug);
|
|
79
|
+
// call the migration's up() directly a second time, bypassing umzug bookkeeping
|
|
80
|
+
// eslint-disable-next-line import/no-dynamic-require, global-require
|
|
81
|
+
const migration = require(path.join(STORE_DIR, 'migrations', `${TENANT_MIGRATION}.ts`));
|
|
82
|
+
await migration.up({ context: harness.sequelize.getQueryInterface() });
|
|
83
|
+
const columns = await tableColumns(harness.sequelize, 'customers');
|
|
84
|
+
expect(columns.filter((c) => c.name === 'instance_did')).toHaveLength(1);
|
|
85
|
+
}, 60000);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('security: migration is built from static literals only', () => {
|
|
89
|
+
it('contains no env/config interpolation into SQL identifiers', () => {
|
|
90
|
+
const migrationSource = fs.readFileSync(path.join(STORE_DIR, 'migrations', `${TENANT_MIGRATION}.ts`), 'utf8');
|
|
91
|
+
expect(migrationSource).not.toContain('process.env');
|
|
92
|
+
expect(migrationSource).not.toContain('globalThis');
|
|
93
|
+
const listSource = fs.readFileSync(path.join(STORE_DIR, 'tenant-tables.ts'), 'utf8');
|
|
94
|
+
expect(listSource).not.toContain('process.env');
|
|
95
|
+
// every table entry is a quoted literal
|
|
96
|
+
const entries = listSource.match(/^\s*'[a-z_]+',$/gm) || [];
|
|
97
|
+
expect(entries.length).toBe(38);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('data loss / data damage: existing rows survive up and down', () => {
|
|
102
|
+
it('preserves pre-existing rows and their values through up, and through down', async () => {
|
|
103
|
+
// migrate to just before the tenant migration, then seed
|
|
104
|
+
await upToPhase1(harness.umzug);
|
|
105
|
+
await harness.umzug.down(); // revert the tenant column migration
|
|
106
|
+
|
|
107
|
+
const qi = harness.sequelize.getQueryInterface();
|
|
108
|
+
const now = new Date().toISOString();
|
|
109
|
+
await qi.bulkInsert('customers', [
|
|
110
|
+
{
|
|
111
|
+
id: 'cus_phase1_test1',
|
|
112
|
+
livemode: 0,
|
|
113
|
+
did: 'z-did-phase1-1',
|
|
114
|
+
delinquent: 0,
|
|
115
|
+
created_at: now,
|
|
116
|
+
updated_at: now,
|
|
117
|
+
},
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
// up: row survives, all original values intact, instance_did = NULL
|
|
121
|
+
await upToPhase1(harness.umzug);
|
|
122
|
+
const [afterUp] = await harness.sequelize.query(
|
|
123
|
+
"SELECT id, livemode, did, delinquent, instance_did FROM customers WHERE id = 'cus_phase1_test1'"
|
|
124
|
+
);
|
|
125
|
+
expect(afterUp).toHaveLength(1);
|
|
126
|
+
const row = (afterUp as any[])[0];
|
|
127
|
+
expect(row.did).toBe('z-did-phase1-1');
|
|
128
|
+
expect(row.livemode).toBe(0);
|
|
129
|
+
expect(row.instance_did).toBeNull();
|
|
130
|
+
|
|
131
|
+
// down: column removed, row and non-tenant values still intact
|
|
132
|
+
await harness.umzug.down();
|
|
133
|
+
const columns = await tableColumns(harness.sequelize, 'customers');
|
|
134
|
+
expect(columns.find((c) => c.name === 'instance_did')).toBeUndefined();
|
|
135
|
+
const [afterDown] = await harness.sequelize.query("SELECT id, did FROM customers WHERE id = 'cus_phase1_test1'");
|
|
136
|
+
expect(afterDown).toHaveLength(1);
|
|
137
|
+
expect((afterDown as any[])[0].did).toBe('z-did-phase1-1');
|
|
138
|
+
}, 120000);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('data leak: the new column is never auto-filled', () => {
|
|
142
|
+
it('defaults to NULL for rows written without an explicit tenant', async () => {
|
|
143
|
+
await upToPhase1(harness.umzug);
|
|
144
|
+
const qi = harness.sequelize.getQueryInterface();
|
|
145
|
+
const now = new Date().toISOString();
|
|
146
|
+
await qi.bulkInsert('products', [
|
|
147
|
+
{
|
|
148
|
+
id: 'prod_phase1_leak',
|
|
149
|
+
livemode: 0,
|
|
150
|
+
active: 1,
|
|
151
|
+
name: 'leak-check',
|
|
152
|
+
type: 'service',
|
|
153
|
+
created_at: now,
|
|
154
|
+
updated_at: now,
|
|
155
|
+
},
|
|
156
|
+
]);
|
|
157
|
+
const [rows] = await harness.sequelize.query("SELECT instance_did FROM products WHERE id = 'prod_phase1_leak'");
|
|
158
|
+
expect((rows as any[])[0].instance_did).toBeNull();
|
|
159
|
+
}, 60000);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -68,7 +68,14 @@ jest.mock('../../src/libs/queue', () => {
|
|
|
68
68
|
delete: jest.fn().mockResolvedValue(undefined),
|
|
69
69
|
on: jest.fn(),
|
|
70
70
|
};
|
|
71
|
-
|
|
71
|
+
const factory: any = jest.fn().mockReturnValue(mockQueue);
|
|
72
|
+
return {
|
|
73
|
+
__esModule: true,
|
|
74
|
+
default: factory,
|
|
75
|
+
// phase 5/6: tenant invariant helper — pass-through in these unit tests
|
|
76
|
+
// (tenant enforcement has its own suites: tenant-matrix-a/b)
|
|
77
|
+
assertJobObjectTenant: jest.fn(),
|
|
78
|
+
};
|
|
72
79
|
});
|
|
73
80
|
|
|
74
81
|
jest.mock('../../src/store/models', () => ({
|
|
@@ -71,7 +71,14 @@ jest.mock('../../src/libs/queue', () => {
|
|
|
71
71
|
delete: jest.fn().mockResolvedValue(undefined),
|
|
72
72
|
on: jest.fn(),
|
|
73
73
|
};
|
|
74
|
-
|
|
74
|
+
const factory: any = jest.fn().mockReturnValue(mockQueue);
|
|
75
|
+
return {
|
|
76
|
+
__esModule: true,
|
|
77
|
+
default: factory,
|
|
78
|
+
// phase 5/6: tenant invariant helper — pass-through in these unit tests
|
|
79
|
+
// (tenant enforcement has its own suites: tenant-matrix-a/b)
|
|
80
|
+
assertJobObjectTenant: jest.fn(),
|
|
81
|
+
};
|
|
75
82
|
});
|
|
76
83
|
|
|
77
84
|
jest.mock('../../src/store/models', () => ({
|
|
@@ -0,0 +1,292 @@
|
|
|
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
|
+
// neutralize the queue engine: real createQueue scans the jobs table on
|
|
17
|
+
// import (crash-recovery), which races against this suite's temp DB lifecycle
|
|
18
|
+
jest.mock('../../src/libs/queue', () => ({
|
|
19
|
+
__esModule: true,
|
|
20
|
+
default: () => ({
|
|
21
|
+
push: jest.fn(),
|
|
22
|
+
pushAndWait: jest.fn(),
|
|
23
|
+
cancel: jest.fn(),
|
|
24
|
+
on: jest.fn(),
|
|
25
|
+
get: jest.fn().mockResolvedValue(null),
|
|
26
|
+
}),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
// the five delivery paths are exercised against mocks for transport pieces
|
|
30
|
+
const addWebhookJob = jest.fn().mockResolvedValue(true);
|
|
31
|
+
jest.mock('../../src/queues/webhook', () => {
|
|
32
|
+
const actual = jest.requireActual('../../src/queues/webhook');
|
|
33
|
+
return { ...actual, addWebhookJob: (...args: any[]) => addWebhookJob(...args) };
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const componentRequest = jest.fn().mockResolvedValue({ status: 200, data: { ok: true } });
|
|
37
|
+
jest.mock('@blocklet/sdk/lib/util/component-api', () => ({
|
|
38
|
+
__esModule: true,
|
|
39
|
+
default: { request: (...args: any[]) => componentRequest(...args) },
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
const STORE_DIR = path.join(__dirname, '../../src/store');
|
|
43
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'event-tenant-'));
|
|
44
|
+
const sequelize = new Sequelize({ dialect: 'sqlite', storage: path.join(dir, 'test.db'), logging: false });
|
|
45
|
+
const umzug = new Umzug({
|
|
46
|
+
migrations: {
|
|
47
|
+
glob: ['migrations/*.ts', { cwd: STORE_DIR }],
|
|
48
|
+
resolve: ({ name, path: p, context }) => {
|
|
49
|
+
// eslint-disable-next-line import/no-dynamic-require, global-require
|
|
50
|
+
const migration = require(p!);
|
|
51
|
+
return {
|
|
52
|
+
name: name.replace(/\.ts$/, '.js'),
|
|
53
|
+
up: () => migration.up({ context }),
|
|
54
|
+
down: () => migration.down({ context }),
|
|
55
|
+
};
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
context: sequelize.getQueryInterface(),
|
|
59
|
+
storage: new SequelizeStorage({ sequelize }),
|
|
60
|
+
logger: undefined,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
let models: any;
|
|
64
|
+
let handleEvent: any;
|
|
65
|
+
let handleWebhook: any;
|
|
66
|
+
let assertEventTenantAccessible: any;
|
|
67
|
+
let startEventQueue: any;
|
|
68
|
+
let eventQueue: any;
|
|
69
|
+
let getInstanceDid: any;
|
|
70
|
+
let logger: any;
|
|
71
|
+
|
|
72
|
+
beforeAll(async () => {
|
|
73
|
+
await umzug.up();
|
|
74
|
+
// eslint-disable-next-line global-require
|
|
75
|
+
models = require('../../src/store/models');
|
|
76
|
+
models.initialize(sequelize);
|
|
77
|
+
// eslint-disable-next-line global-require
|
|
78
|
+
({ handleEvent, startEventQueue, eventQueue } = require('../../src/queues/event'));
|
|
79
|
+
// eslint-disable-next-line global-require
|
|
80
|
+
({ getInstanceDid } = require('../../src/libs/context'));
|
|
81
|
+
// eslint-disable-next-line global-require
|
|
82
|
+
({ handleWebhook } = jest.requireActual('../../src/queues/webhook'));
|
|
83
|
+
// eslint-disable-next-line global-require
|
|
84
|
+
({ assertEventTenantAccessible } = require('../../src/routes/hono/events'));
|
|
85
|
+
// eslint-disable-next-line global-require
|
|
86
|
+
logger = require('../../src/libs/logger').default;
|
|
87
|
+
}, 120000);
|
|
88
|
+
|
|
89
|
+
afterAll(async () => {
|
|
90
|
+
await sequelize.close();
|
|
91
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const seedEvent = (tenant: string, type = 'customer.updated') =>
|
|
95
|
+
withTenant(tenant, () =>
|
|
96
|
+
models.Event.create({
|
|
97
|
+
type,
|
|
98
|
+
instance_did: tenant,
|
|
99
|
+
api_version: 'test',
|
|
100
|
+
livemode: false,
|
|
101
|
+
object_id: 'obj_1',
|
|
102
|
+
object_type: 'customer',
|
|
103
|
+
data: { object: { id: 'obj_1' } },
|
|
104
|
+
request: { id: '', idempotency_key: '', requested_by: 'test' },
|
|
105
|
+
metadata: {},
|
|
106
|
+
pending_webhooks: 99,
|
|
107
|
+
})
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const seedEndpoint = (tenant: string, url: string) =>
|
|
111
|
+
withTenant(tenant, () =>
|
|
112
|
+
models.WebhookEndpoint.create({
|
|
113
|
+
instance_did: tenant,
|
|
114
|
+
livemode: false,
|
|
115
|
+
url,
|
|
116
|
+
description: 'test',
|
|
117
|
+
status: 'enabled',
|
|
118
|
+
enabled_events: ['customer.updated'],
|
|
119
|
+
secret: 'whsec_test',
|
|
120
|
+
api_version: 'test',
|
|
121
|
+
})
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
beforeEach(async () => {
|
|
125
|
+
jest.clearAllMocks();
|
|
126
|
+
await sequelize.query('DELETE FROM events');
|
|
127
|
+
await sequelize.query('DELETE FROM webhook_endpoints');
|
|
128
|
+
await sequelize.query('DELETE FROM webhook_attempts');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('event delivery tenant isolation (phase 4)', () => {
|
|
132
|
+
describe('path 1 fanout (queues/event.ts): only same-tenant endpoints scheduled', () => {
|
|
133
|
+
it('A event fans out to A endpoint only, B endpoint untouched', async () => {
|
|
134
|
+
const event = await seedEvent(TENANT_A);
|
|
135
|
+
const endpointA = await seedEndpoint(TENANT_A, 'http://a.example.com/hook');
|
|
136
|
+
await seedEndpoint(TENANT_B, 'http://b.example.com/hook');
|
|
137
|
+
|
|
138
|
+
await handleEvent({ eventId: event.id });
|
|
139
|
+
|
|
140
|
+
expect(addWebhookJob).toHaveBeenCalledTimes(1);
|
|
141
|
+
expect(addWebhookJob).toHaveBeenCalledWith(event.id, endpointA.id, expect.anything());
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('path 2 delivery handler (queues/webhook.ts): tenant invariant', () => {
|
|
146
|
+
it('forged job pairing A event with B endpoint is refused without an attempt row', async () => {
|
|
147
|
+
const event = await seedEvent(TENANT_A);
|
|
148
|
+
const endpointB = await seedEndpoint(TENANT_B, 'http://b.example.com/hook');
|
|
149
|
+
|
|
150
|
+
await handleWebhook({ eventId: event.id, webhookId: endpointB.id });
|
|
151
|
+
|
|
152
|
+
expect(componentRequest).not.toHaveBeenCalled();
|
|
153
|
+
const [attempts] = await sequelize.query('SELECT COUNT(*) AS n FROM webhook_attempts');
|
|
154
|
+
expect((attempts as any[])[0].n).toBe(0);
|
|
155
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
156
|
+
expect.stringContaining('tenant mismatch'),
|
|
157
|
+
expect.objectContaining({ code: TENANT_MISMATCH })
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('same-tenant delivery succeeds and the attempt row carries the tenant', async () => {
|
|
162
|
+
const event = await seedEvent(TENANT_A);
|
|
163
|
+
const endpointA = await seedEndpoint(TENANT_A, 'http://a.example.com/hook');
|
|
164
|
+
|
|
165
|
+
await handleWebhook({ eventId: event.id, webhookId: endpointA.id });
|
|
166
|
+
|
|
167
|
+
expect(componentRequest).toHaveBeenCalledTimes(1);
|
|
168
|
+
const [attempts] = await sequelize.query('SELECT status, instance_did FROM webhook_attempts');
|
|
169
|
+
expect(attempts).toEqual([{ status: 'succeeded', instance_did: TENANT_A }]);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('paths 3+4 manual retry routes: caller tenant guard', () => {
|
|
174
|
+
it('A caller cannot retry a B event (TENANT_MISMATCH -> 4xx mapping)', async () => {
|
|
175
|
+
await withTenant(TENANT_A, () => {
|
|
176
|
+
expect(() => assertEventTenantAccessible({ instance_did: TENANT_B })).toThrow(
|
|
177
|
+
expect.objectContaining({ code: TENANT_MISMATCH })
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('multi mode without caller context fails closed', () => {
|
|
183
|
+
process.env.PAYMENT_TENANT_MODE = 'multi';
|
|
184
|
+
try {
|
|
185
|
+
expect(() => assertEventTenantAccessible({ instance_did: TENANT_B })).toThrow(
|
|
186
|
+
expect.objectContaining({ code: TENANT_CONTEXT_MISSING })
|
|
187
|
+
);
|
|
188
|
+
} finally {
|
|
189
|
+
delete process.env.PAYMENT_TENANT_MODE;
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('same-tenant caller passes the guard', async () => {
|
|
194
|
+
await withTenant(TENANT_B, () => {
|
|
195
|
+
expect(() => assertEventTenantAccessible({ instance_did: TENANT_B })).not.toThrow();
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('retry fanout never schedules the other tenant endpoint (paths 3+4 data leak)', async () => {
|
|
200
|
+
// same shape the retry routes use after their tenant guard: endpoint
|
|
201
|
+
// query scoped by caller tenant -> B endpoint invisible to A
|
|
202
|
+
await seedEndpoint(TENANT_A, 'http://a.example.com/hook');
|
|
203
|
+
await seedEndpoint(TENANT_B, 'http://b.example.com/hook');
|
|
204
|
+
const visible = await withTenant(TENANT_A, () =>
|
|
205
|
+
models.WebhookEndpoint.findAll({
|
|
206
|
+
where: { status: 'enabled', livemode: false, instance_did: TENANT_A },
|
|
207
|
+
})
|
|
208
|
+
);
|
|
209
|
+
expect(visible).toHaveLength(1);
|
|
210
|
+
expect(visible[0].url).toBe('http://a.example.com/hook');
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe('path 5 pending scan (crons/retry-pending-events.ts)', () => {
|
|
215
|
+
it('re-enqueued events still fan out tenant-filtered (transitively via handleEvent)', async () => {
|
|
216
|
+
// the cron only re-enqueues IDs; prove the downstream filter holds for a
|
|
217
|
+
// B event when both tenants have endpoints
|
|
218
|
+
const event = await seedEvent(TENANT_B);
|
|
219
|
+
await seedEndpoint(TENANT_A, 'http://a.example.com/hook');
|
|
220
|
+
const endpointB = await seedEndpoint(TENANT_B, 'http://b.example.com/hook');
|
|
221
|
+
|
|
222
|
+
await handleEvent({ eventId: event.id });
|
|
223
|
+
|
|
224
|
+
expect(addWebhookJob).toHaveBeenCalledTimes(1);
|
|
225
|
+
expect(addWebhookJob).toHaveBeenCalledWith(event.id, endpointB.id, expect.anything());
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe('startup recovery (startEventQueue): pending-webhook events re-enqueued under their tenant', () => {
|
|
230
|
+
const ORIGINAL_MODE = process.env.PAYMENT_TENANT_MODE;
|
|
231
|
+
afterEach(() => {
|
|
232
|
+
if (ORIGINAL_MODE === undefined) delete process.env.PAYMENT_TENANT_MODE;
|
|
233
|
+
else process.env.PAYMENT_TENANT_MODE = ORIGINAL_MODE;
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// Regression: in multi mode startup has NO ambient tenant. The old recovery
|
|
237
|
+
// pushed outside withTenant (and fetched only id), so injectJobTenant ->
|
|
238
|
+
// getInstanceDid threw TENANT_CONTEXT_MISSING — a FATAL unhandledRejection
|
|
239
|
+
// that crashed the daemon. The push must run inside withTenant(event tenant).
|
|
240
|
+
it('multi mode: recovers a pending event INSIDE its tenant context (no crash)', async () => {
|
|
241
|
+
process.env.PAYMENT_TENANT_MODE = 'multi';
|
|
242
|
+
const event = await seedEvent(TENANT_A);
|
|
243
|
+
|
|
244
|
+
let tenantAtPush: string | undefined;
|
|
245
|
+
let threwAtPush = false;
|
|
246
|
+
(eventQueue.push as jest.Mock).mockImplementationOnce(() => {
|
|
247
|
+
try {
|
|
248
|
+
tenantAtPush = getInstanceDid(); // would THROW in multi mode if outside withTenant
|
|
249
|
+
} catch {
|
|
250
|
+
threwAtPush = true;
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
await expect(startEventQueue()).resolves.toBeUndefined();
|
|
255
|
+
expect(eventQueue.push).toHaveBeenCalledWith(
|
|
256
|
+
expect.objectContaining({ id: event.id, job: { eventId: event.id } })
|
|
257
|
+
);
|
|
258
|
+
expect(threwAtPush).toBe(false);
|
|
259
|
+
expect(tenantAtPush).toBe(TENANT_A);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// A row with no tenant cannot be re-stamped — skip it (warn), never crash.
|
|
263
|
+
it('multi mode: a tenant-less pending event is skipped, not pushed', async () => {
|
|
264
|
+
process.env.PAYMENT_TENANT_MODE = 'multi';
|
|
265
|
+
const event = await seedEvent(TENANT_A);
|
|
266
|
+
// null out the tenant directly (bypass the scoped writer)
|
|
267
|
+
await sequelize.query('UPDATE events SET instance_did = NULL WHERE id = $id', {
|
|
268
|
+
bind: { id: event.id },
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
await expect(startEventQueue()).resolves.toBeUndefined();
|
|
272
|
+
expect(eventQueue.push).not.toHaveBeenCalled();
|
|
273
|
+
expect(logger.warn).toHaveBeenCalledWith(
|
|
274
|
+
'skip pending-webhook event with no tenant',
|
|
275
|
+
expect.objectContaining({ id: event.id })
|
|
276
|
+
);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
describe('data damage: retry keeps the original tenant', () => {
|
|
281
|
+
it('failed delivery writes a failed attempt under the event tenant', async () => {
|
|
282
|
+
componentRequest.mockRejectedValueOnce(Object.assign(new Error('boom'), { response: { status: 500 } }));
|
|
283
|
+
const event = await seedEvent(TENANT_A);
|
|
284
|
+
const endpointA = await seedEndpoint(TENANT_A, 'http://a.example.com/hook');
|
|
285
|
+
|
|
286
|
+
await handleWebhook({ eventId: event.id, webhookId: endpointA.id });
|
|
287
|
+
|
|
288
|
+
const [attempts] = await sequelize.query('SELECT status, instance_did FROM webhook_attempts');
|
|
289
|
+
expect(attempts).toEqual([{ status: 'failed', instance_did: TENANT_A }]);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// D6 — the exchange-rate-health schedule carries its tenant in the job PAYLOAD,
|
|
2
|
+
// so the re-schedule (on 'finished', which fires OUTSIDE any withTenant scope)
|
|
3
|
+
// stays under the correct tenant without relying on the ALS context. In multi
|
|
4
|
+
// mode a tenant-less push would throw TENANT_CONTEXT_MISSING.
|
|
5
|
+
|
|
6
|
+
jest.mock('../../src/libs/logger', () => ({
|
|
7
|
+
__esModule: true,
|
|
8
|
+
default: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
const pushed: any[] = [];
|
|
12
|
+
let finishedListener: ((data: any) => void) | undefined;
|
|
13
|
+
|
|
14
|
+
jest.mock('../../src/libs/queue', () => ({
|
|
15
|
+
__esModule: true,
|
|
16
|
+
default: () => ({
|
|
17
|
+
push: (p: any) => {
|
|
18
|
+
pushed.push(p);
|
|
19
|
+
return { on: jest.fn() };
|
|
20
|
+
},
|
|
21
|
+
on: (ev: string, cb: any) => {
|
|
22
|
+
if (ev === 'finished') finishedListener = cb;
|
|
23
|
+
},
|
|
24
|
+
pushAndWait: jest.fn(),
|
|
25
|
+
cancel: jest.fn(),
|
|
26
|
+
get: jest.fn(),
|
|
27
|
+
store: { addJob: jest.fn(), getScheduledJobs: jest.fn().mockResolvedValue([]) },
|
|
28
|
+
stop: jest.fn(),
|
|
29
|
+
}),
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
import { scheduleHealthChecks } from '../../src/queues/exchange-rate-health';
|
|
33
|
+
|
|
34
|
+
describe('D6 — exchange-rate-health carries the tenant in the job payload', () => {
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
pushed.length = 0;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('the initial schedule pushes a job tagged with instance_did', () => {
|
|
40
|
+
scheduleHealthChecks('did:abt:zHEALTHA');
|
|
41
|
+
expect(pushed.length).toBe(1);
|
|
42
|
+
expect(pushed[0].job.instance_did).toBe('did:abt:zHEALTHA');
|
|
43
|
+
expect(pushed[0].persist).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('the re-schedule preserves the FINISHED job’s tenant (no ALS reliance)', () => {
|
|
47
|
+
scheduleHealthChecks('did:abt:zHEALTHA');
|
|
48
|
+
expect(finishedListener).toBeDefined();
|
|
49
|
+
pushed.length = 0;
|
|
50
|
+
// simulate a finished job that belonged to tenant B — the next schedule must
|
|
51
|
+
// be for B, taken from the job payload, NOT from any ambient context.
|
|
52
|
+
finishedListener!({ job: { type: 'health_check', timestamp: 1, instance_did: 'did:abt:zHEALTHB' } });
|
|
53
|
+
expect(pushed.length).toBe(1);
|
|
54
|
+
expect(pushed[0].job.instance_did).toBe('did:abt:zHEALTHB');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('single mode (no instanceDid) pushes without a forced tenant (default-tenant fallback applies)', () => {
|
|
58
|
+
scheduleHealthChecks(undefined);
|
|
59
|
+
expect(pushed.length).toBe(1);
|
|
60
|
+
expect(pushed[0].job.instance_did).toBeUndefined(); // injectJobTenant uses the default tenant in single
|
|
61
|
+
});
|
|
62
|
+
});
|