payment-kit 1.29.0 → 1.29.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api/dev.ts +41 -2
- package/api/hono.d.ts +42 -0
- package/api/node-sqlite.d.ts +12 -0
- package/api/src/bootstrap.ts +36 -0
- package/api/src/crons/base.ts +3 -3
- package/api/src/crons/currency.ts +1 -1
- package/api/src/crons/index.ts +27 -24
- package/api/src/crons/metering-subscription-detection.ts +1 -1
- package/api/src/crons/overdue-detection.ts +2 -2
- package/api/src/crons/retry-pending-events.ts +6 -0
- package/api/src/index.ts +22 -161
- package/api/src/integrations/app-store/client.ts +3 -4
- package/api/src/integrations/app-store/handlers/subscription.ts +7 -7
- package/api/src/integrations/app-store/signed-data-verifier.ts +3 -2
- package/api/src/integrations/arcblock/token.ts +21 -7
- package/api/src/integrations/google-play/handlers/subscription.ts +6 -6
- package/api/src/integrations/google-play/handlers/voided.ts +2 -2
- package/api/src/integrations/google-play/verify.ts +3 -2
- package/api/src/integrations/iap-reconcile.ts +3 -5
- package/api/src/integrations/stripe/handlers/invoice.ts +2 -2
- package/api/src/integrations/stripe/handlers/subscription.ts +3 -3
- package/api/src/libs/archive/query.ts +19 -0
- package/api/src/libs/audit.ts +61 -4
- package/api/src/libs/auth.ts +99 -38
- package/api/src/libs/context.ts +78 -1
- package/api/src/libs/currency.ts +2 -2
- package/api/src/libs/dayjs.ts +8 -2
- package/api/src/libs/drivers/auth-storage.ts +118 -0
- package/api/src/libs/drivers/cron.ts +264 -0
- package/api/src/libs/drivers/db.ts +170 -0
- package/api/src/libs/drivers/identity.ts +81 -0
- package/api/src/libs/drivers/index.ts +40 -0
- package/api/src/libs/drivers/locks.ts +226 -0
- package/api/src/libs/drivers/migrate-runner.ts +70 -0
- package/api/src/libs/drivers/queue.ts +104 -0
- package/api/src/libs/drivers/secrets.ts +194 -0
- package/api/src/libs/env.ts +170 -54
- package/api/src/libs/exchange-rate/service.ts +7 -6
- package/api/src/libs/http-fetch-adapter.ts +50 -0
- package/api/src/libs/invoice.ts +1 -1
- package/api/src/libs/lock.ts +51 -47
- package/api/src/libs/logger.ts +48 -8
- package/api/src/libs/notification/index.ts +1 -1
- package/api/src/libs/notification/template/customer-credit-low-balance.ts +2 -1
- package/api/src/libs/notification/template/customer-revenue-succeeded.ts +1 -1
- package/api/src/libs/notification/template/customer-reward-succeeded.ts +1 -1
- package/api/src/libs/overdraft-protection.ts +1 -1
- package/api/src/libs/payout.ts +1 -1
- package/api/src/libs/queue/index.ts +259 -52
- package/api/src/libs/queue/runtime.ts +175 -0
- package/api/src/libs/resource.ts +3 -3
- package/api/src/libs/secrets.ts +38 -0
- package/api/src/libs/session.ts +3 -2
- package/api/src/libs/subscription.ts +5 -5
- package/api/src/libs/tenant.ts +92 -0
- package/api/src/libs/url.ts +3 -3
- package/api/src/libs/util.ts +21 -13
- package/api/src/middlewares/hono/cdn.ts +63 -0
- package/api/src/middlewares/hono/context.ts +73 -0
- package/api/src/middlewares/hono/csrf.ts +72 -0
- package/api/src/middlewares/hono/fallback.ts +194 -0
- package/api/src/middlewares/hono/pipeline.ts +73 -0
- package/api/src/middlewares/hono/resource-mount.ts +42 -0
- package/api/src/middlewares/hono/resource.ts +63 -0
- package/api/src/middlewares/hono/security.ts +214 -0
- package/api/src/middlewares/hono/session.ts +114 -0
- package/api/src/middlewares/hono/xss.ts +61 -0
- package/api/src/queues/auto-recharge.ts +12 -10
- package/api/src/queues/checkout-session.ts +17 -12
- package/api/src/queues/credit-consume.ts +40 -36
- package/api/src/queues/credit-grant.ts +25 -18
- package/api/src/queues/credit-reconciliation.ts +7 -5
- package/api/src/queues/discount-status.ts +9 -6
- package/api/src/queues/event.ts +12 -4
- package/api/src/queues/exchange-rate-health.ts +49 -30
- package/api/src/queues/invoice.ts +18 -15
- package/api/src/queues/notification.ts +14 -7
- package/api/src/queues/payment.ts +41 -28
- package/api/src/queues/payout.ts +9 -5
- package/api/src/queues/refund.ts +18 -12
- package/api/src/queues/subscription.ts +83 -53
- package/api/src/queues/token-transfer.ts +15 -10
- package/api/src/queues/usage-record.ts +8 -5
- package/api/src/queues/vendors/commission.ts +7 -5
- package/api/src/queues/vendors/fulfillment-coordinator.ts +17 -13
- package/api/src/queues/vendors/fulfillment.ts +4 -2
- package/api/src/queues/vendors/return-processor.ts +5 -3
- package/api/src/queues/vendors/return-scanner.ts +5 -4
- package/api/src/queues/vendors/status-check.ts +10 -7
- package/api/src/queues/webhook.ts +60 -32
- package/api/src/routes/connect/shared.ts +1 -2
- package/api/src/routes/connect/subscribe.ts +3 -3
- package/api/src/routes/{archive.ts → hono/archive.ts} +69 -64
- package/api/src/routes/{auto-recharge-configs.ts → hono/auto-recharge-configs.ts} +39 -28
- package/api/src/routes/{checkout-sessions.ts → hono/checkout-sessions.ts} +790 -923
- package/api/src/routes/{coupons.ts → hono/coupons.ts} +93 -76
- package/api/src/routes/{credit-grants.ts → hono/credit-grants.ts} +140 -126
- package/api/src/routes/hono/credit-tokens.ts +43 -0
- package/api/src/routes/{credit-transactions.ts → hono/credit-transactions.ts} +37 -29
- package/api/src/routes/{customers.ts → hono/customers.ts} +193 -223
- package/api/src/routes/{donations.ts → hono/donations.ts} +41 -32
- package/api/src/routes/{entitlements.ts → hono/entitlements.ts} +28 -25
- package/api/src/routes/{events.ts → hono/events.ts} +107 -71
- package/api/src/routes/{exchange-rate-providers.ts → hono/exchange-rate-providers.ts} +138 -126
- package/api/src/routes/hono/exchange-rates.ts +77 -0
- package/api/src/routes/hono/index.ts +115 -0
- package/api/src/routes/{integrations → hono/integrations}/app-store.ts +68 -48
- package/api/src/routes/{integrations → hono/integrations}/google-play.ts +78 -58
- package/api/src/routes/hono/integrations/stripe.ts +74 -0
- package/api/src/routes/{invoices.ts → hono/invoices.ts} +253 -244
- package/api/src/routes/{meter-events.ts → hono/meter-events.ts} +120 -110
- package/api/src/routes/hono/meters.ts +288 -0
- package/api/src/routes/hono/passports.ts +73 -0
- package/api/src/routes/{payment-currencies.ts → hono/payment-currencies.ts} +219 -197
- package/api/src/routes/{payment-intents.ts → hono/payment-intents.ts} +136 -132
- package/api/src/routes/{payment-links.ts → hono/payment-links.ts} +145 -128
- package/api/src/routes/{payment-methods.ts → hono/payment-methods.ts} +125 -93
- package/api/src/routes/{payment-stats.ts → hono/payment-stats.ts} +30 -25
- package/api/src/routes/{payouts.ts → hono/payouts.ts} +55 -47
- package/api/src/routes/{prices.ts → hono/prices.ts} +265 -242
- package/api/src/routes/{pricing-table.ts → hono/pricing-table.ts} +94 -87
- package/api/src/routes/{products.ts → hono/products.ts} +172 -159
- package/api/src/routes/{promotion-codes.ts → hono/promotion-codes.ts} +207 -185
- package/api/src/routes/hono/redirect.ts +24 -0
- package/api/src/routes/{refunds.ts → hono/refunds.ts} +96 -80
- package/api/src/routes/{settings.ts → hono/settings.ts} +64 -55
- package/api/src/routes/{subscription-items.ts → hono/subscription-items.ts} +64 -57
- package/api/src/routes/{subscriptions.ts → hono/subscriptions.ts} +475 -528
- package/api/src/routes/{tax-rates.ts → hono/tax-rates.ts} +71 -70
- package/api/src/routes/hono/tool.ts +69 -0
- package/api/src/routes/{usage-records.ts → hono/usage-records.ts} +47 -42
- package/api/src/routes/{vendor.ts → hono/vendor.ts} +315 -167
- package/api/src/routes/{webhook-attempts.ts → hono/webhook-attempts.ts} +17 -13
- package/api/src/routes/hono/webhook-endpoints.ts +126 -0
- package/api/src/service.ts +667 -0
- package/api/src/store/migrations/20230911-seeding.ts +2 -1
- package/api/src/store/migrations/20260609-remove-did-space-jobs.ts +23 -0
- package/api/src/store/migrations/20260610-tenant-columns.ts +40 -0
- package/api/src/store/migrations/20260611-tenant-backfill.ts +33 -0
- package/api/src/store/models/auto-recharge-config.ts +22 -10
- package/api/src/store/models/checkout-session.ts +15 -14
- package/api/src/store/models/coupon.ts +29 -20
- package/api/src/store/models/credit-grant.ts +38 -29
- package/api/src/store/models/credit-transaction.ts +32 -21
- package/api/src/store/models/customer.ts +19 -17
- package/api/src/store/models/discount.ts +11 -2
- package/api/src/store/models/entitlement-grant.ts +21 -9
- package/api/src/store/models/entitlement-product.ts +21 -9
- package/api/src/store/models/entitlement.ts +19 -10
- package/api/src/store/models/event.ts +18 -9
- package/api/src/store/models/exchange-rate-provider.ts +17 -4
- package/api/src/store/models/invoice-item.ts +18 -9
- package/api/src/store/models/invoice.ts +16 -8
- package/api/src/store/models/meter-event.ts +27 -9
- package/api/src/store/models/meter.ts +31 -22
- package/api/src/store/models/payment-currency.ts +25 -8
- package/api/src/store/models/payment-intent.ts +15 -6
- package/api/src/store/models/payment-link.ts +15 -6
- package/api/src/store/models/payment-method.ts +38 -22
- package/api/src/store/models/payment-stat.ts +18 -9
- package/api/src/store/models/payout.ts +15 -6
- package/api/src/store/models/price-quote.ts +17 -8
- package/api/src/store/models/price.ts +24 -12
- package/api/src/store/models/pricing-table.ts +29 -20
- package/api/src/store/models/product-vendor.ts +20 -10
- package/api/src/store/models/product.ts +15 -6
- package/api/src/store/models/promotion-code.ts +14 -6
- package/api/src/store/models/refund.ts +15 -6
- package/api/src/store/models/revenue-snapshot.ts +21 -9
- package/api/src/store/models/setting.ts +18 -9
- package/api/src/store/models/setup-intent.ts +36 -27
- package/api/src/store/models/subscription-item.ts +21 -9
- package/api/src/store/models/subscription-schedule.ts +21 -9
- package/api/src/store/models/subscription.ts +21 -10
- package/api/src/store/models/tax-rate.ts +29 -21
- package/api/src/store/models/usage-record.ts +11 -2
- package/api/src/store/models/webhook-attempt.ts +18 -9
- package/api/src/store/models/webhook-endpoint.ts +18 -9
- package/api/src/store/scoped-core.ts +55 -0
- package/api/src/store/scoped.ts +247 -0
- package/api/src/store/sequelize.ts +66 -22
- package/api/src/store/sql-migrations.ts +20 -0
- package/api/src/store/tenant-backfill.ts +260 -0
- package/api/src/store/tenant-model.ts +124 -0
- package/api/src/store/tenant-tables.ts +50 -0
- package/api/tests/embedded/embedded-multi-mode-d3.spec.ts +257 -0
- package/api/tests/fixtures/bare-query-violation.ts +13 -0
- package/api/tests/fixtures/core-env-violation.ts +10 -0
- package/api/tests/fixtures/host-read-violation.ts +19 -0
- package/api/tests/fixtures/tenants.ts +4 -0
- package/api/tests/integrations/iap-tenant.spec.ts +284 -0
- package/api/tests/libs/archive-query.spec.ts +26 -0
- package/api/tests/libs/audit-tenant.spec.ts +153 -0
- package/api/tests/libs/context.spec.ts +204 -0
- package/api/tests/libs/core-config.spec.ts +115 -0
- package/api/tests/libs/cron-driver-d2.spec.ts +237 -0
- package/api/tests/libs/crons-conservation-d2.spec.ts +52 -0
- package/api/tests/libs/lock-tenant.spec.ts +66 -0
- package/api/tests/libs/scoped.spec.ts +222 -0
- package/api/tests/libs/secrets-facade.spec.ts +52 -0
- package/api/tests/libs/tenancy-slot-authority.spec.ts +209 -0
- package/api/tests/libs/tenant-middleware.spec.ts +42 -0
- package/api/tests/libs/tenant-scanner.spec.ts +120 -0
- package/api/tests/middlewares/hono/cdn.spec.ts +70 -0
- package/api/tests/middlewares/hono/context.spec.ts +113 -0
- package/api/tests/middlewares/hono/csrf.spec.ts +136 -0
- package/api/tests/middlewares/hono/fallback.spec.ts +67 -0
- package/api/tests/middlewares/hono/pipeline.spec.ts +47 -0
- package/api/tests/middlewares/hono/security.spec.ts +181 -0
- package/api/tests/middlewares/hono/session.spec.ts +42 -0
- package/api/tests/middlewares/hono/xss.spec.ts +81 -0
- package/api/tests/models/tenant-backfill.spec.ts +287 -0
- package/api/tests/models/tenant-columns-model.spec.ts +46 -0
- package/api/tests/models/tenant-columns.spec.ts +161 -0
- package/api/tests/queues/credit-consume-batch.spec.ts +8 -1
- package/api/tests/queues/credit-consume.spec.ts +8 -1
- package/api/tests/queues/event-tenant.spec.ts +236 -0
- package/api/tests/queues/exchange-rate-health-tenant-d6.spec.ts +62 -0
- package/api/tests/queues/queue-parity.spec.ts +249 -0
- package/api/tests/queues/queue-runtime-surface.spec.ts +277 -0
- package/api/tests/queues/queue-teardown-d2.spec.ts +127 -0
- package/api/tests/queues/tenant-matrix-a.spec.ts +245 -0
- package/api/tests/queues/tenant-matrix-b.spec.ts +168 -0
- package/api/tests/routes/connect/hono-attach.spec.ts +107 -0
- package/api/tests/service/collapse.spec.ts +96 -0
- package/api/tests/store/tenant-crosscut.spec.ts +202 -0
- package/api/tests/store/tenant-model-spike.spec.ts +177 -0
- package/api/tests/store/tenant-model.spec.ts +162 -0
- package/api/tests/store/tenant-residual.spec.ts +196 -0
- package/api/third.d.ts +4 -0
- package/blocklet.yml +1 -1
- package/cloudflare/README.md +26 -6
- package/cloudflare/build.ts +28 -13
- package/cloudflare/did-connect-auth.ts +0 -217
- package/cloudflare/docs/2026-06-10-bundle-size-analysis.md +288 -0
- package/cloudflare/migrations/0006_tenant_columns.sql +46 -0
- package/cloudflare/migrations/0007_tenant_backfill_indexes.sql +65 -0
- package/cloudflare/migrations/0008_schema_parity.sql +16 -0
- package/cloudflare/migrations/0009_remove_did_space_jobs.sql +5 -0
- package/cloudflare/queue-runtime-mode.ts +13 -0
- package/cloudflare/run-build.js +31 -56
- package/cloudflare/shims/blocklet-sdk/asset-host-transformer.ts +20 -0
- package/cloudflare/shims/blocklet-sdk/config.ts +8 -1
- package/cloudflare/shims/blocklet-sdk/login.ts +12 -0
- package/cloudflare/shims/blocklet-sdk/service-api.ts +14 -0
- package/cloudflare/shims/blocklet-sdk/session.ts +4 -2
- package/cloudflare/shims/blocklet-sdk/util-constants.ts +8 -0
- package/cloudflare/shims/blocklet-sdk/util-csrf.ts +13 -0
- package/cloudflare/shims/blocklet-sdk/util-wallet.ts +8 -0
- package/cloudflare/shims/cron.ts +38 -158
- package/cloudflare/shims/events.ts +124 -0
- package/cloudflare/shims/fastq.ts +15 -1
- package/cloudflare/shims/nedb-storage.ts +16 -8
- package/cloudflare/shims/node-fetch.ts +35 -0
- package/cloudflare/shims/xss.ts +8 -0
- package/cloudflare/tenant-middleware.ts +36 -0
- package/cloudflare/tests/tenant-middleware.spec.ts +160 -0
- package/cloudflare/tests/worker-handler-gate.spec.ts +44 -0
- package/cloudflare/worker.ts +204 -433
- package/cloudflare/wrangler.local-e2e.jsonc +26 -0
- package/jest.config.js +3 -1
- package/package.json +33 -38
- package/scripts/core-env-whitelist.json +1 -0
- package/scripts/e2e-12b-runtime.ts +149 -0
- package/scripts/e2e-core-config.ts +125 -0
- package/scripts/e2e-d1-tenancy.ts +116 -0
- package/scripts/e2e-d2-cron-queue.ts +139 -0
- package/scripts/e2e-d3-embedded-multi.ts +171 -0
- package/scripts/e2e-hono-s2.ts +125 -0
- package/scripts/e2e-hono-s3e.ts +135 -0
- package/scripts/e2e-hono-s4.ts +114 -0
- package/scripts/e2e-migration-contract.ts +100 -0
- package/scripts/e2e-s0.ts +61 -0
- package/scripts/e2e-s1.ts +107 -0
- package/scripts/e2e-s2.ts +178 -0
- package/scripts/e2e-s3.ts +110 -0
- package/scripts/e2e-s4.ts +191 -0
- package/scripts/e2e-s5.ts +139 -0
- package/scripts/e2e-s6.ts +127 -0
- package/scripts/e2e-tenant-model.ts +119 -0
- package/scripts/e2e-tenant-worker.ts +199 -0
- package/scripts/gen-sql-migrations.js +46 -0
- package/scripts/phase8-codemod.js +219 -0
- package/scripts/phase9a-env-getters-codemod.js +82 -0
- package/scripts/scan-core-env.js +109 -0
- package/scripts/scan-tenant-queries.js +235 -0
- package/scripts/schema-drift-guard.ts +210 -0
- package/scripts/tenant-scan-whitelist.json +1 -0
- package/src/env.d.ts +13 -1
- package/tsconfig.json +1 -1
- package/api/src/libs/did-space.ts +0 -235
- package/api/src/libs/middleware.ts +0 -50
- package/api/src/libs/security.ts +0 -192
- package/api/src/queues/space.ts +0 -662
- package/api/src/routes/credit-tokens.ts +0 -38
- package/api/src/routes/exchange-rates.ts +0 -87
- package/api/src/routes/index.ts +0 -142
- package/api/src/routes/integrations/stripe.ts +0 -61
- package/api/src/routes/meters.ts +0 -274
- package/api/src/routes/passports.ts +0 -68
- package/api/src/routes/redirect.ts +0 -20
- package/api/src/routes/tool.ts +0 -65
- package/api/src/routes/webhook-endpoints.ts +0 -126
- package/api/tests/routes/credit-grants.spec.ts +0 -1261
- package/cloudflare/shims/did-space-js.ts +0 -17
- package/cloudflare/shims/did-space.ts +0 -11
- package/cloudflare/shims/express-compat/index.ts +0 -80
- package/cloudflare/shims/express-compat/types.ts +0 -41
- package/cloudflare/shims/lock.ts +0 -115
- package/cloudflare/shims/queue.ts +0 -611
- package/cloudflare/tests/shims/queue-delayed-persist.spec.ts +0 -87
- package/cloudflare/tests/shims/queue-scheduled.spec.ts +0 -186
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// Phase 12b (W2′): core queue RUNTIME surface parity probes.
|
|
2
|
+
//
|
|
3
|
+
// These lock the contract the CF worker now drives through the service/slot
|
|
4
|
+
// boundary instead of cloudflare/shims/queue.ts:
|
|
5
|
+
// - the queue REGISTRY is non-empty under the canonical (node-engine) build
|
|
6
|
+
// - a due delayed job dispatches + executes EXACTLY once via dispatchDueJobs()
|
|
7
|
+
// - a cancelled delayed job is NOT executed by dispatchDueJobs() (no
|
|
8
|
+
// half-execution residue — the spec's mandatory negative case)
|
|
9
|
+
// - retry / nonRetryable / maxRetries match the node engine
|
|
10
|
+
// - the handler runs under the PAYLOAD tenant; a cross-tenant object is
|
|
11
|
+
// fail-closed
|
|
12
|
+
// - in 'workerd' mode the background loop() is disabled (a frozen isolate
|
|
13
|
+
// must not run a timer) and an immediate push is not lost: flushQueueWork()
|
|
14
|
+
// drains it before the (simulated) response
|
|
15
|
+
// - the queue() consumer can resolve a core handler by name (no undefined ack)
|
|
16
|
+
//
|
|
17
|
+
// These supersede the deleted cloudflare/tests/shims/queue-{scheduled,delayed-
|
|
18
|
+
// persist}.spec.ts: those probed the removed cloudflare/shims/queue.ts duplicate
|
|
19
|
+
// engine (D1 + CF-Queue-send + inline-fallback). Under the canonical node engine
|
|
20
|
+
// the worker never sends to CF Queue (immediate jobs run in-isolate via fastq),
|
|
21
|
+
// so those CF-Queue-fallback scenarios no longer exist; due-delayed dispatch is
|
|
22
|
+
// covered here by dispatchDueJobs().
|
|
23
|
+
|
|
24
|
+
import fs from 'fs';
|
|
25
|
+
import os from 'os';
|
|
26
|
+
import path from 'path';
|
|
27
|
+
import { Sequelize } from 'sequelize';
|
|
28
|
+
import { SequelizeStorage, Umzug } from 'umzug';
|
|
29
|
+
|
|
30
|
+
import { withTenant, getInstanceDid } from '../../src/libs/context';
|
|
31
|
+
import {
|
|
32
|
+
setQueueRuntimeMode,
|
|
33
|
+
getQueueHandler,
|
|
34
|
+
getAllQueueNames,
|
|
35
|
+
dispatchDueJobs,
|
|
36
|
+
flushQueueWork,
|
|
37
|
+
__test__ as runtimeTest,
|
|
38
|
+
} from '../../src/libs/queue/runtime';
|
|
39
|
+
|
|
40
|
+
/* eslint-disable global-require, require-await, no-promise-executor-return */
|
|
41
|
+
|
|
42
|
+
// delayed/retry cases do real per-attempt DB ops on a shared sqlite file; give
|
|
43
|
+
// the suite a generous timeout (I/O-bound, not hung).
|
|
44
|
+
jest.setTimeout(30000);
|
|
45
|
+
|
|
46
|
+
jest.mock('../../src/libs/logger', () => ({
|
|
47
|
+
__esModule: true,
|
|
48
|
+
default: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
const STORE_DIR = path.join(__dirname, '../../src/store');
|
|
52
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'queue-runtime-'));
|
|
53
|
+
const sequelize = new Sequelize({ dialect: 'sqlite', storage: path.join(dir, 'test.db'), logging: false });
|
|
54
|
+
const umzug = new Umzug({
|
|
55
|
+
migrations: {
|
|
56
|
+
glob: ['migrations/*.ts', { cwd: STORE_DIR }],
|
|
57
|
+
resolve: ({ name, path: p, context }) => {
|
|
58
|
+
const migration = require(p!);
|
|
59
|
+
return {
|
|
60
|
+
name: name.replace(/\.ts$/, '.js'),
|
|
61
|
+
up: () => migration.up({ context }),
|
|
62
|
+
down: () => migration.down({ context }),
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
context: sequelize.getQueryInterface(),
|
|
67
|
+
storage: new SequelizeStorage({ sequelize }),
|
|
68
|
+
logger: undefined,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
let createQueue: any;
|
|
72
|
+
const TENANT_A = 'did:abt:zRUNTIMEA';
|
|
73
|
+
const TENANT_B = 'did:abt:zRUNTIMEB';
|
|
74
|
+
|
|
75
|
+
const settle = (emitter: any): Promise<{ event: string; data: any }> =>
|
|
76
|
+
new Promise((resolve) => {
|
|
77
|
+
['finished', 'failed', 'cancelled'].forEach((e) => emitter.on(e, (data: any) => resolve({ event: e, data })));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
beforeAll(async () => {
|
|
81
|
+
await umzug.up();
|
|
82
|
+
const models = require('../../src/store/models');
|
|
83
|
+
models.initialize(sequelize);
|
|
84
|
+
createQueue = require('../../src/libs/queue').default;
|
|
85
|
+
}, 120000);
|
|
86
|
+
|
|
87
|
+
afterAll(async () => {
|
|
88
|
+
await sequelize.close();
|
|
89
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
beforeEach(async () => {
|
|
93
|
+
await sequelize.query('DELETE FROM jobs');
|
|
94
|
+
setQueueRuntimeMode('node');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
afterEach(() => {
|
|
98
|
+
// clears the registry + restores 'node' so each test is self-contained
|
|
99
|
+
runtimeTest.reset();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('registry + consumer lookup', () => {
|
|
103
|
+
it('createQueue registers the handle into the runtime registry (non-empty)', () => {
|
|
104
|
+
const name = `reg-${Date.now()}`;
|
|
105
|
+
const q = createQueue({ name, onJob: async () => undefined });
|
|
106
|
+
expect(getAllQueueNames()).toContain(name);
|
|
107
|
+
expect(getQueueHandler(name)).toBe(q);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('the queue() consumer resolves a core handler by name and runs onJob once (no undefined ack)', async () => {
|
|
111
|
+
let runs = 0;
|
|
112
|
+
const name = `consume-${Date.now()}`;
|
|
113
|
+
createQueue({ name, onJob: async () => { runs += 1; return 'ok'; } });
|
|
114
|
+
|
|
115
|
+
const handle = getQueueHandler(name);
|
|
116
|
+
expect(handle).toBeTruthy();
|
|
117
|
+
const res: any = await withTenant(TENANT_A, async () =>
|
|
118
|
+
handle.pushAndWait({ job: { v: 1, instance_did: TENANT_A } })
|
|
119
|
+
);
|
|
120
|
+
expect(res.result).toBe('ok');
|
|
121
|
+
expect(runs).toBe(1);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('scheduled dispatch (delay) parity — host-driven dispatchDueJobs()', () => {
|
|
126
|
+
it("'workerd' mode disables the background loop (no auto-dispatch)", async () => {
|
|
127
|
+
setQueueRuntimeMode('workerd');
|
|
128
|
+
let ran = 0;
|
|
129
|
+
const name = `wd-loop-${Date.now()}`;
|
|
130
|
+
const q = createQueue({ name, onJob: async () => { ran += 1; }, options: { enableScheduledJob: true } });
|
|
131
|
+
// a due delayed row sitting in D1
|
|
132
|
+
await q.store.addJob('wd-1', { v: 1, instance_did: TENANT_A }, { delay: 5, will_run_at: Date.now() - 1000 });
|
|
133
|
+
// a node loop would have polled + run it by now; workerd must not
|
|
134
|
+
await new Promise((r) => setTimeout(r, 60));
|
|
135
|
+
expect(ran).toBe(0);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('a due delayed job dispatches and executes exactly once via dispatchDueJobs()', async () => {
|
|
139
|
+
setQueueRuntimeMode('workerd');
|
|
140
|
+
let ran = 0;
|
|
141
|
+
const seenTenant: string[] = [];
|
|
142
|
+
const name = `wd-due-${Date.now()}`;
|
|
143
|
+
const q = createQueue({
|
|
144
|
+
name,
|
|
145
|
+
onJob: async () => { ran += 1; seenTenant.push(getInstanceDid()); },
|
|
146
|
+
options: { enableScheduledJob: true },
|
|
147
|
+
});
|
|
148
|
+
await q.store.addJob('due-1', { v: 1, instance_did: TENANT_A }, { delay: 5, will_run_at: Date.now() - 1000 });
|
|
149
|
+
|
|
150
|
+
const r = await dispatchDueJobs();
|
|
151
|
+
await flushQueueWork(); // drain the re-dispatched immediate execution
|
|
152
|
+
|
|
153
|
+
expect(r.dispatched).toBe(1);
|
|
154
|
+
expect(ran).toBe(1);
|
|
155
|
+
expect(seenTenant).toEqual([TENANT_A]); // ran under the payload tenant
|
|
156
|
+
// row cleared after success
|
|
157
|
+
expect(await q.store.getJob('due-1')).toBeNull();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('a cancelled delayed job is NOT executed by dispatchDueJobs() (no half-execution residue)', async () => {
|
|
161
|
+
setQueueRuntimeMode('workerd');
|
|
162
|
+
let ran = 0;
|
|
163
|
+
const name = `wd-cancel-${Date.now()}`;
|
|
164
|
+
const q = createQueue({ name, onJob: async () => { ran += 1; }, options: { enableScheduledJob: true } });
|
|
165
|
+
await q.store.addJob('c-1', { v: 1, instance_did: TENANT_A }, { delay: 5, will_run_at: Date.now() - 1000 });
|
|
166
|
+
await q.cancel('c-1'); // marks cancelled=true BEFORE the due-dispatch tick
|
|
167
|
+
|
|
168
|
+
const r = await dispatchDueJobs();
|
|
169
|
+
await flushQueueWork();
|
|
170
|
+
|
|
171
|
+
expect(r.dispatched).toBe(0);
|
|
172
|
+
expect(ran).toBe(0);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('node entry: a cancelled delayed row is excluded from due dispatch (same filter both entries use)', async () => {
|
|
176
|
+
setQueueRuntimeMode('node');
|
|
177
|
+
// no enableScheduledJob → no background loop leaks; we assert the exact
|
|
178
|
+
// store filter (cancelled:false) that BOTH the node loop() and the workerd
|
|
179
|
+
// dispatchDueJobs() select due rows with, so cancel parity is structural.
|
|
180
|
+
const q = createQueue({ name: `node-cancel-${Date.now()}`, onJob: async () => undefined });
|
|
181
|
+
await q.store.addJob('nc-1', { v: 1, instance_did: TENANT_A }, { delay: 5, will_run_at: Date.now() - 1000 });
|
|
182
|
+
expect((await q.store.getScheduledJobs()).some((j: any) => j.id === 'nc-1')).toBe(true);
|
|
183
|
+
await q.cancel('nc-1');
|
|
184
|
+
expect((await q.store.getScheduledJobs()).some((j: any) => j.id === 'nc-1')).toBe(false);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe('retry / nonRetryable parity (node engine semantics)', () => {
|
|
189
|
+
it('retries up to maxRetries then fails (initial + retries, no infinite loop)', async () => {
|
|
190
|
+
let attempts = 0;
|
|
191
|
+
const q = createQueue({
|
|
192
|
+
name: `rt-retry-${Date.now()}`,
|
|
193
|
+
onJob: async () => { attempts += 1; throw new Error('always'); },
|
|
194
|
+
options: { maxRetries: 3, retryDelay: 1 },
|
|
195
|
+
});
|
|
196
|
+
const ev = q.push({ job: { v: 1, instance_did: TENANT_A } });
|
|
197
|
+
const { event } = await settle(ev);
|
|
198
|
+
expect(event).toBe('failed');
|
|
199
|
+
expect(attempts).toBe(3);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('a nonRetryable error fails immediately (single attempt)', async () => {
|
|
203
|
+
let attempts = 0;
|
|
204
|
+
const q = createQueue({
|
|
205
|
+
name: `rt-nonretry-${Date.now()}`,
|
|
206
|
+
onJob: async () => {
|
|
207
|
+
attempts += 1;
|
|
208
|
+
const err: any = new Error('forged');
|
|
209
|
+
err.nonRetryable = true;
|
|
210
|
+
throw err;
|
|
211
|
+
},
|
|
212
|
+
options: { maxRetries: 5, retryDelay: 1 },
|
|
213
|
+
});
|
|
214
|
+
const ev = q.push({ job: { v: 1, instance_did: TENANT_A } });
|
|
215
|
+
const { event } = await settle(ev);
|
|
216
|
+
expect(event).toBe('failed');
|
|
217
|
+
expect(attempts).toBe(1);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe('tenant safety', () => {
|
|
222
|
+
it('handler runs under the payload tenant; a cross-tenant object is fail-closed', async () => {
|
|
223
|
+
const { assertJobObjectTenant } = require('../../src/libs/queue');
|
|
224
|
+
let leaked = false;
|
|
225
|
+
let observedTenant = '';
|
|
226
|
+
const q = createQueue({
|
|
227
|
+
name: `tn-${Date.now()}`,
|
|
228
|
+
onJob: async (_job: any) => {
|
|
229
|
+
observedTenant = getInstanceDid();
|
|
230
|
+
// simulate an object loaded cross-tenant (payload says A, row says B)
|
|
231
|
+
assertJobObjectTenant({ instance_did: TENANT_B });
|
|
232
|
+
leaked = true; // must be unreachable — assert throws first
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
const ev = await withTenant(TENANT_A, async () => q.push({ job: { tag: 'x' } }));
|
|
236
|
+
const { event } = await settle(ev);
|
|
237
|
+
expect(observedTenant).toBe(TENANT_A);
|
|
238
|
+
expect(leaked).toBe(false);
|
|
239
|
+
expect(event).toBe('failed');
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe('workerd flush — immediate push not lost', () => {
|
|
244
|
+
it('flushQueueWork() drains an in-flight immediate push before the response', async () => {
|
|
245
|
+
setQueueRuntimeMode('workerd');
|
|
246
|
+
let ran = 0;
|
|
247
|
+
const q = createQueue({ name: `flush-${Date.now()}`, onJob: async () => { ran += 1; } });
|
|
248
|
+
// fire-and-forget immediate push (the worker does this inside a request)
|
|
249
|
+
q.push({ job: { v: 1, instance_did: TENANT_A } });
|
|
250
|
+
// simulate the host draining before the isolate freezes
|
|
251
|
+
await flushQueueWork();
|
|
252
|
+
expect(ran).toBe(1);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('flushQueueWork() is a no-op on node (nothing tracked, returns immediately)', async () => {
|
|
256
|
+
setQueueRuntimeMode('node');
|
|
257
|
+
await expect(flushQueueWork()).resolves.toBeUndefined();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('a duplicate immediate push does not hang flushQueueWork() (tracker released on addJob failure)', async () => {
|
|
261
|
+
setQueueRuntimeMode('workerd');
|
|
262
|
+
let runs = 0;
|
|
263
|
+
const q = createQueue({ name: `flush-dup-${Date.now()}`, onJob: async () => { runs += 1; } });
|
|
264
|
+
q.push({ job: { v: 1, instance_did: TENANT_A }, id: 'dup-flush' });
|
|
265
|
+
q.push({ job: { v: 1, instance_did: TENANT_A }, id: 'dup-flush' }); // duplicate → never enqueues
|
|
266
|
+
// must resolve, not hang on the never-settling duplicate
|
|
267
|
+
await flushQueueWork();
|
|
268
|
+
expect(runs).toBe(1);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe('test harness sanity', () => {
|
|
273
|
+
it('runtime __test__.reset clears the registry', () => {
|
|
274
|
+
createQueue({ name: `sanity-${Date.now()}`, onJob: async () => undefined });
|
|
275
|
+
expect(runtimeTest.registrySize()).toBeGreaterThan(0);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// D2 (S3.0) — the node queue poll loop is cancelable. On a Node host the
|
|
2
|
+
// per-scheduled-queue loop() polls due delayed rows on a timer; lifecycle.stop()
|
|
3
|
+
// calls stopAllQueues() so no poll timer survives a stop / ARC_PAYMENT toggle
|
|
4
|
+
// (the spec's "active handles 归零"). This proves: (1) the loop self-dispatches a
|
|
5
|
+
// due job on node, (2) after stopAllQueues() it no longer dispatches AND its
|
|
6
|
+
// sleep timer is gone.
|
|
7
|
+
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import os from 'os';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import { Sequelize } from 'sequelize';
|
|
12
|
+
import { SequelizeStorage, Umzug } from 'umzug';
|
|
13
|
+
|
|
14
|
+
import { withTenant } from '../../src/libs/context';
|
|
15
|
+
import { setQueueRuntimeMode, stopAllQueues, __test__ as runtimeTest } from '../../src/libs/queue/runtime';
|
|
16
|
+
|
|
17
|
+
jest.setTimeout(30000);
|
|
18
|
+
|
|
19
|
+
jest.mock('../../src/libs/logger', () => ({
|
|
20
|
+
__esModule: true,
|
|
21
|
+
default: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
const STORE_DIR = path.join(__dirname, '../../src/store');
|
|
25
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'queue-teardown-'));
|
|
26
|
+
const sequelize = new Sequelize({ dialect: 'sqlite', storage: path.join(dir, 'test.db'), logging: false });
|
|
27
|
+
const umzug = new Umzug({
|
|
28
|
+
migrations: {
|
|
29
|
+
glob: ['migrations/*.ts', { cwd: STORE_DIR }],
|
|
30
|
+
resolve: ({ name, path: p, context }) => {
|
|
31
|
+
const migration = require(p!);
|
|
32
|
+
return {
|
|
33
|
+
name: name.replace(/\.ts$/, '.js'),
|
|
34
|
+
up: () => migration.up({ context }),
|
|
35
|
+
down: () => migration.down({ context }),
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
context: sequelize.getQueryInterface(),
|
|
40
|
+
storage: new SequelizeStorage({ sequelize }),
|
|
41
|
+
logger: undefined,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
let createQueue: any;
|
|
45
|
+
const TENANT = 'did:abt:zTEARDOWNA';
|
|
46
|
+
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
47
|
+
const countTimers = () =>
|
|
48
|
+
(process as any).getActiveResourcesInfo().filter((r: string) => r === 'Timeout').length;
|
|
49
|
+
|
|
50
|
+
beforeAll(async () => {
|
|
51
|
+
await umzug.up();
|
|
52
|
+
const models = require('../../src/store/models');
|
|
53
|
+
models.initialize(sequelize);
|
|
54
|
+
createQueue = require('../../src/libs/queue').default;
|
|
55
|
+
}, 120000);
|
|
56
|
+
|
|
57
|
+
afterAll(async () => {
|
|
58
|
+
await sequelize.close();
|
|
59
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
beforeEach(async () => {
|
|
63
|
+
await sequelize.query('DELETE FROM jobs');
|
|
64
|
+
setQueueRuntimeMode('node');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
stopAllQueues();
|
|
69
|
+
runtimeTest.reset();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('D2 — node queue loop self-dispatches a due delayed job (positive)', () => {
|
|
73
|
+
it('a due delayed row is picked up by the node loop with no host tick', async () => {
|
|
74
|
+
let ran = 0;
|
|
75
|
+
const name = `loop-pos-${Date.now()}`;
|
|
76
|
+
const q = createQueue({
|
|
77
|
+
name,
|
|
78
|
+
onJob: async () => {
|
|
79
|
+
ran += 1;
|
|
80
|
+
},
|
|
81
|
+
options: { enableScheduledJob: true },
|
|
82
|
+
});
|
|
83
|
+
await q.store.addJob('due-1', { v: 1, instance_did: TENANT }, { delay: 5, will_run_at: Date.now() - 1000 });
|
|
84
|
+
await wait(1600); // loop polls every minDelay/2 = 1s in test env
|
|
85
|
+
expect(ran).toBeGreaterThanOrEqual(1);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('D2 — stopAllQueues() stops the loop and clears its timer (teardown)', () => {
|
|
90
|
+
it('after stopAllQueues() a due delayed row is NOT auto-dispatched', async () => {
|
|
91
|
+
let ran = 0;
|
|
92
|
+
const name = `loop-stop-${Date.now()}`;
|
|
93
|
+
const q = createQueue({
|
|
94
|
+
name,
|
|
95
|
+
onJob: async () => {
|
|
96
|
+
ran += 1;
|
|
97
|
+
},
|
|
98
|
+
options: { enableScheduledJob: true },
|
|
99
|
+
});
|
|
100
|
+
const withLoop = countTimers();
|
|
101
|
+
stopAllQueues(); // teardown before the first tick fires
|
|
102
|
+
const afterStop = countTimers();
|
|
103
|
+
// the loop's pending sleep timer is gone
|
|
104
|
+
expect(afterStop).toBeLessThan(withLoop);
|
|
105
|
+
|
|
106
|
+
await q.store.addJob('due-2', { v: 1, instance_did: TENANT }, { delay: 5, will_run_at: Date.now() - 1000 });
|
|
107
|
+
await wait(1600);
|
|
108
|
+
expect(ran).toBe(0); // loop is dead — nothing dispatched it
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('the queue handle exposes a stop() function', () => {
|
|
112
|
+
const q = createQueue({
|
|
113
|
+
name: `has-stop-${Date.now()}`,
|
|
114
|
+
onJob: async () => {},
|
|
115
|
+
options: { enableScheduledJob: true },
|
|
116
|
+
});
|
|
117
|
+
expect(typeof q.stop).toBe('function');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('pushAndWait still works after a stop (immediate path unaffected by loop teardown)', async () => {
|
|
121
|
+
const name = `imm-${Date.now()}`;
|
|
122
|
+
const q = createQueue({ name, onJob: async () => 'ok', options: { enableScheduledJob: true } });
|
|
123
|
+
stopAllQueues();
|
|
124
|
+
const res: any = await withTenant(TENANT, async () => q.pushAndWait({ job: { v: 1, instance_did: TENANT } }));
|
|
125
|
+
expect(res.result).toBe('ok');
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,245 @@
|
|
|
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 { systemFindByPk } from '../../src/store/scoped';
|
|
10
|
+
import { TENANT_A, TENANT_B } from '../fixtures/tenants';
|
|
11
|
+
|
|
12
|
+
jest.mock('../../src/libs/logger', () => ({
|
|
13
|
+
__esModule: true,
|
|
14
|
+
default: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
const STORE_DIR = path.join(__dirname, '../../src/store');
|
|
18
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tenant-matrix-a-'));
|
|
19
|
+
const sequelize = new Sequelize({ dialect: 'sqlite', storage: path.join(dir, 'test.db'), logging: false });
|
|
20
|
+
const umzug = new Umzug({
|
|
21
|
+
migrations: {
|
|
22
|
+
glob: ['migrations/*.ts', { cwd: STORE_DIR }],
|
|
23
|
+
resolve: ({ name, path: p, context }) => {
|
|
24
|
+
// eslint-disable-next-line import/no-dynamic-require, global-require
|
|
25
|
+
const migration = require(p!);
|
|
26
|
+
return {
|
|
27
|
+
name: name.replace(/\.ts$/, '.js'),
|
|
28
|
+
up: () => migration.up({ context }),
|
|
29
|
+
down: () => migration.down({ context }),
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
context: sequelize.getQueryInterface(),
|
|
34
|
+
storage: new SequelizeStorage({ sequelize }),
|
|
35
|
+
logger: undefined,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
let models: any;
|
|
39
|
+
let createQueue: any;
|
|
40
|
+
let assertJobObjectTenant: any;
|
|
41
|
+
|
|
42
|
+
beforeAll(async () => {
|
|
43
|
+
await umzug.up();
|
|
44
|
+
// eslint-disable-next-line global-require
|
|
45
|
+
models = require('../../src/store/models');
|
|
46
|
+
models.initialize(sequelize);
|
|
47
|
+
// eslint-disable-next-line global-require
|
|
48
|
+
const queueModule = require('../../src/libs/queue');
|
|
49
|
+
createQueue = queueModule.default;
|
|
50
|
+
assertJobObjectTenant = queueModule.assertJobObjectTenant;
|
|
51
|
+
}, 120000);
|
|
52
|
+
|
|
53
|
+
afterAll(async () => {
|
|
54
|
+
await sequelize.close();
|
|
55
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const waitFor = (emitter: any, events: string[]): Promise<{ event: string; data: any }> =>
|
|
59
|
+
new Promise((resolve) => {
|
|
60
|
+
for (const event of events) {
|
|
61
|
+
emitter.on(event, (data: any) => resolve({ event, data }));
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('queue tenant layer — first batch (phase 5)', () => {
|
|
66
|
+
beforeEach(async () => {
|
|
67
|
+
await sequelize.query('DELETE FROM customers');
|
|
68
|
+
await sequelize.query('DELETE FROM jobs');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('happy path', () => {
|
|
72
|
+
it('push stamps the active tenant into the payload, job row included, handler runs in that tenant', async () => {
|
|
73
|
+
const seen: string[] = [];
|
|
74
|
+
const queue = createQueue({
|
|
75
|
+
name: `tm-happy-${Date.now()}`,
|
|
76
|
+
onJob: async (job: any) => {
|
|
77
|
+
// eslint-disable-next-line global-require
|
|
78
|
+
const { getInstanceDid } = require('../../src/libs/context');
|
|
79
|
+
seen.push(job.instance_did, getInstanceDid());
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const handle = await withTenant(TENANT_A, async () => queue.push({ job: { businessId: 'x1' }, persist: true }));
|
|
84
|
+
await waitFor(handle, ['finished', 'failed']);
|
|
85
|
+
expect(seen).toEqual([TENANT_A, TENANT_A]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('handler object guard passes for matching tenants', async () => {
|
|
89
|
+
const customer = await withTenant(TENANT_A, () =>
|
|
90
|
+
models.Customer.create({ livemode: false, did: 'z-m-a', delinquent: false, instance_did: TENANT_A })
|
|
91
|
+
);
|
|
92
|
+
await withTenant(TENANT_A, async () => {
|
|
93
|
+
expect(() => assertJobObjectTenant(customer)).not.toThrow();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('bad input', () => {
|
|
99
|
+
it('multi mode push without tenant context is rejected at the gate', async () => {
|
|
100
|
+
process.env.PAYMENT_TENANT_MODE = 'multi';
|
|
101
|
+
try {
|
|
102
|
+
const queue = createQueue({ name: `tm-bad-${Date.now()}`, onJob: async () => {} });
|
|
103
|
+
expect(() => queue.push({ job: { businessId: 'x' } })).toThrow(
|
|
104
|
+
expect.objectContaining({ code: TENANT_CONTEXT_MISSING })
|
|
105
|
+
);
|
|
106
|
+
} finally {
|
|
107
|
+
delete process.env.PAYMENT_TENANT_MODE;
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('illegal tenant DID in the payload is refused', () => {
|
|
112
|
+
const queue = createQueue({ name: `tm-bad2-${Date.now()}`, onJob: async () => {} });
|
|
113
|
+
expect(() => queue.push({ job: { businessId: 'x', instance_did: 'a b' } })).toThrow(
|
|
114
|
+
expect.objectContaining({ code: TENANT_CONTEXT_MISSING })
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('security: forged payload tenant vs object tenant', () => {
|
|
120
|
+
it('handler refuses to act on another tenant object; the object is untouched', async () => {
|
|
121
|
+
const victim = await withTenant(TENANT_B, () =>
|
|
122
|
+
models.Customer.create({ livemode: false, did: 'z-victim', delinquent: false, instance_did: TENANT_B })
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
let observedError: any;
|
|
126
|
+
const queue = createQueue({
|
|
127
|
+
name: `tm-forged-${Date.now()}`,
|
|
128
|
+
onJob: async (job: any) => {
|
|
129
|
+
// handlers load the tenant-stamped object cross-tenant (system) then
|
|
130
|
+
// enforce its tenant — mirrors the real queue handlers so a forged
|
|
131
|
+
// payload surfaces an observable TENANT_MISMATCH rather than folding
|
|
132
|
+
// into a scoped null (tenant-design §10).
|
|
133
|
+
const row: any = await systemFindByPk(models.Customer, job.customerId);
|
|
134
|
+
assertJobObjectTenant(row); // throws TENANT_MISMATCH
|
|
135
|
+
await row.update({ name: 'pwned' });
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
// forge: payload claims TENANT_A but targets B's customer
|
|
139
|
+
const handle = await withTenant(TENANT_A, async () =>
|
|
140
|
+
queue.push({ job: { customerId: victim.id }, persist: true })
|
|
141
|
+
);
|
|
142
|
+
const outcome = await waitFor(handle, ['failed', 'finished']);
|
|
143
|
+
observedError = (outcome.data as any).error;
|
|
144
|
+
|
|
145
|
+
expect(outcome.event).toBe('failed');
|
|
146
|
+
expect(observedError?.code).toBe(TENANT_MISMATCH);
|
|
147
|
+
const reloaded: any = await systemFindByPk(models.Customer, victim.id);
|
|
148
|
+
expect(reloaded.name).toBeNull(); // not 'pwned'
|
|
149
|
+
expect(reloaded.instance_did).toBe(TENANT_B);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('data loss: refused jobs surface as failures, not silence', () => {
|
|
154
|
+
it('the failed event fires with the tenant error attached', async () => {
|
|
155
|
+
const queue = createQueue({
|
|
156
|
+
name: `tm-fail-${Date.now()}`,
|
|
157
|
+
onJob: async () => {
|
|
158
|
+
const err: any = new Error('refused');
|
|
159
|
+
err.code = TENANT_MISMATCH;
|
|
160
|
+
err.nonRetryable = true;
|
|
161
|
+
throw err;
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
const handle = await withTenant(TENANT_A, async () => queue.push({ job: { businessId: 'x' }, persist: true }));
|
|
165
|
+
const outcome = await waitFor(handle, ['failed', 'finished']);
|
|
166
|
+
expect(outcome.event).toBe('failed');
|
|
167
|
+
expect((outcome.data as any).error?.code).toBe(TENANT_MISMATCH);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe('data damage: retries keep the original tenant', () => {
|
|
172
|
+
it('a retried job re-executes under the same tenant, never a different one', async () => {
|
|
173
|
+
const tenantsSeen: string[] = [];
|
|
174
|
+
const queue = createQueue({
|
|
175
|
+
name: `tm-retry-${Date.now()}`,
|
|
176
|
+
onJob: async (job: any) => {
|
|
177
|
+
tenantsSeen.push(job.instance_did);
|
|
178
|
+
if (tenantsSeen.length < 2) throw new Error('transient');
|
|
179
|
+
},
|
|
180
|
+
options: { maxRetries: 2, retryDelay: 1 },
|
|
181
|
+
});
|
|
182
|
+
const handle = await withTenant(TENANT_B, async () => queue.push({ job: { businessId: 'r1' }, persist: true }));
|
|
183
|
+
await waitFor(handle, ['finished', 'failed']);
|
|
184
|
+
expect(tenantsSeen.length).toBeGreaterThanOrEqual(2);
|
|
185
|
+
expect(new Set(tenantsSeen)).toEqual(new Set([TENANT_B]));
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// NOTE: spec's "channel-identifier job id prefix" row converts to a no-op
|
|
190
|
+
// for Phase 5 queues — all job ids are internal globally-unique business
|
|
191
|
+
// ids (see queue-matrix.md); channel-identifier dedup moves to Phase 6
|
|
192
|
+
// integrations as (instance_did, identifier) composite keys.
|
|
193
|
+
describe('data leak: legacy job strategy', () => {
|
|
194
|
+
it('a pre-tenant job (no instance_did) executes under the default tenant in single mode', async () => {
|
|
195
|
+
const seen: string[] = [];
|
|
196
|
+
const queue = createQueue({
|
|
197
|
+
name: `tm-legacy-${Date.now()}`,
|
|
198
|
+
onJob: async () => {
|
|
199
|
+
// eslint-disable-next-line global-require
|
|
200
|
+
const { getInstanceDid } = require('../../src/libs/context');
|
|
201
|
+
seen.push(getInstanceDid());
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
// simulate a legacy row: bypass push's injection by writing the store directly
|
|
205
|
+
await queue.store.addJob('legacy-1', { businessId: 'legacy' }, {});
|
|
206
|
+
const row = await queue.get('legacy-1');
|
|
207
|
+
expect(row.instance_did).toBeUndefined();
|
|
208
|
+
// re-deliver it the way startup recovery does (push with persist=false keeps payload as-is? no — push injects)
|
|
209
|
+
// execution path: runJobWithTenant falls back to the default tenant
|
|
210
|
+
const handle = queue.push({
|
|
211
|
+
job: row,
|
|
212
|
+
id: 'legacy-1',
|
|
213
|
+
persist: false,
|
|
214
|
+
skipDuplicateCheck: true,
|
|
215
|
+
fromStore: true,
|
|
216
|
+
});
|
|
217
|
+
await waitFor(handle, ['finished', 'failed']);
|
|
218
|
+
// eslint-disable-next-line global-require
|
|
219
|
+
const { getDefaultInstanceDid } = require('../../src/libs/tenant');
|
|
220
|
+
expect(seen).toEqual([getDefaultInstanceDid()]);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('a legacy job is refused permanently in multi mode (structured, non-retryable)', async () => {
|
|
224
|
+
const queue = createQueue({
|
|
225
|
+
name: `tm-legacy-multi-${Date.now()}`,
|
|
226
|
+
onJob: async () => 'should-not-run',
|
|
227
|
+
});
|
|
228
|
+
process.env.PAYMENT_TENANT_MODE = 'multi';
|
|
229
|
+
try {
|
|
230
|
+
const handle = queue.push({
|
|
231
|
+
job: { businessId: 'legacy-multi' },
|
|
232
|
+
id: 'legacy-multi-1',
|
|
233
|
+
persist: false,
|
|
234
|
+
fromStore: true, // store re-delivery path: gate skipped, execution-side refuses
|
|
235
|
+
});
|
|
236
|
+
const outcome = await waitFor(handle, ['failed', 'finished']);
|
|
237
|
+
expect(outcome.event).toBe('failed');
|
|
238
|
+
expect((outcome.data as any).error?.code).toBe(TENANT_CONTEXT_MISSING);
|
|
239
|
+
expect((outcome.data as any).error?.nonRetryable).toBe(true);
|
|
240
|
+
} finally {
|
|
241
|
+
delete process.env.PAYMENT_TENANT_MODE;
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
});
|