From dee15870477946090bfbcc24508a60ddc31f9366 Mon Sep 17 00:00:00 2001 From: Charlie Lamb Date: Mon, 27 Apr 2026 19:55:41 +0100 Subject: [PATCH 1/5] chore: fix checkout ref issue --- apps/checkout/vite.config.ts | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/apps/checkout/vite.config.ts b/apps/checkout/vite.config.ts index 596a84be7..533d129cf 100644 --- a/apps/checkout/vite.config.ts +++ b/apps/checkout/vite.config.ts @@ -7,9 +7,25 @@ import tsconfigPaths from "vite-tsconfig-paths"; export default defineConfig({ plugins: [react(), tsconfigPaths(), tailwindcss()], resolve: { - alias: { - "@": path.resolve(__dirname, "./src"), - }, + dedupe: ["react", "react-dom"], + alias: [ + { find: "@", replacement: path.resolve(__dirname, "./src") }, + { + find: /^react$/, + replacement: path.resolve(__dirname, "./node_modules/react"), + }, + { + find: /^react-dom$/, + replacement: path.resolve(__dirname, "./node_modules/react-dom"), + }, + { + find: /^react\/jsx-runtime$/, + replacement: path.resolve( + __dirname, + "./node_modules/react/jsx-runtime.js", + ), + }, + ], }, optimizeDeps: { exclude: ["@autumn/shared", "zod/v4"], From 5696839fc00e67736a9069a8f36e2b64950e773f Mon Sep 17 00:00:00 2001 From: Charlie Lamb Date: Mon, 27 Apr 2026 20:00:50 +0100 Subject: [PATCH 2/5] chore: pr review comments --- apps/checkout/vite.config.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/apps/checkout/vite.config.ts b/apps/checkout/vite.config.ts index 533d129cf..fbba21760 100644 --- a/apps/checkout/vite.config.ts +++ b/apps/checkout/vite.config.ts @@ -1,9 +1,12 @@ +import { createRequire } from "node:module"; import path from "node:path"; import tailwindcss from "@tailwindcss/vite"; import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; +const require = createRequire(import.meta.url); + export default defineConfig({ plugins: [react(), tsconfigPaths(), tailwindcss()], resolve: { @@ -12,18 +15,23 @@ export default defineConfig({ { find: "@", replacement: path.resolve(__dirname, "./src") }, { find: /^react$/, - replacement: path.resolve(__dirname, "./node_modules/react"), + replacement: require.resolve("react"), }, { find: /^react-dom$/, - replacement: path.resolve(__dirname, "./node_modules/react-dom"), + replacement: require.resolve("react-dom"), + }, + { + find: /^react-dom\/client$/, + replacement: require.resolve("react-dom/client"), }, { find: /^react\/jsx-runtime$/, - replacement: path.resolve( - __dirname, - "./node_modules/react/jsx-runtime.js", - ), + replacement: require.resolve("react/jsx-runtime"), + }, + { + find: /^react\/jsx-dev-runtime$/, + replacement: require.resolve("react/jsx-dev-runtime"), }, ], }, From ce100dbf2de4164e63d00e46b44c3498d8fd3e17 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Tue, 28 Apr 2026 09:45:08 +0100 Subject: [PATCH 3/5] fix: cache sanitization --- ai | 2 +- .../src/internal/balances/track/v3/runTrackV3.ts | 8 ++++++++ .../fullSubject/sanitize/normalizeFromSchema.ts | 7 +++++-- .../sanitize/sanitizeCachedFullSubject.ts | 16 ++++++++++++++-- .../getCachedFullCustomer.ts | 10 ++++++++++ .../src/utils/cacheUtils/normalizeFromSchema.ts | 12 ++++++++++-- 6 files changed, 48 insertions(+), 7 deletions(-) diff --git a/ai b/ai index a04ded379..e66bb8079 160000 --- a/ai +++ b/ai @@ -1 +1 @@ -Subproject commit a04ded379f43b3c8a23d7cc33eb125cce557500b +Subproject commit e66bb80796bf8387e4fbcc550f9d520eaa182f6e diff --git a/server/src/internal/balances/track/v3/runTrackV3.ts b/server/src/internal/balances/track/v3/runTrackV3.ts index b39dc7437..a9291195e 100644 --- a/server/src/internal/balances/track/v3/runTrackV3.ts +++ b/server/src/internal/balances/track/v3/runTrackV3.ts @@ -13,6 +13,7 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { getOrCreateCachedFullSubject } from "@/internal/customers/cache/fullSubject/actions/getOrCreateCachedFullSubject.js"; import { getOrSetCachedFullSubject } from "@/internal/customers/cache/fullSubject/actions/getOrSetCachedFullSubject.js"; import type { FeatureDeduction } from "../../utils/types/featureDeduction.js"; +import { handleEventIdempotencyKey } from "../utils/handleEventIdempotencyKey.js"; import { runRedisTrackV3 } from "./runRedisTrackV3.js"; import { getTrackIdempotencyKey } from "./trackIdempotencyKey.js"; @@ -64,6 +65,13 @@ export const runTrackV3 = async ({ body, }); + if (body.idempotency_key) { + await handleEventIdempotencyKey({ + ctx, + body, + }); + } + const redisIdempotencyKey = getTrackIdempotencyKey({ ctx }); const response: TrackResponseV3 = await runRedisTrackV3({ diff --git a/server/src/internal/customers/cache/fullSubject/sanitize/normalizeFromSchema.ts b/server/src/internal/customers/cache/fullSubject/sanitize/normalizeFromSchema.ts index 9ba0ea4ea..b4b9a8be3 100644 --- a/server/src/internal/customers/cache/fullSubject/sanitize/normalizeFromSchema.ts +++ b/server/src/internal/customers/cache/fullSubject/sanitize/normalizeFromSchema.ts @@ -87,10 +87,13 @@ const normalize = (schema: ZodSchema, data: unknown): unknown => { const unwrapped = unwrapSchema(schema); if (unwrapped instanceof z.ZodObject) { - if (!isPlainObject(data)) return data; + // Upstash Lua cjson collapses empty `{}` to `[]`; treat as empty object + // so nested defaults (e.g. ProductConfigSchema) still get applied. + const objectData = isEmptyArray(data) ? {} : data; + if (!isPlainObject(objectData)) return data; const shape = unwrapped._def.shape; - const normalized: Record = { ...data }; + const normalized: Record = { ...objectData }; for (const key of Object.keys(shape)) { normalized[key] = normalize(shape[key], normalized[key]); } diff --git a/server/src/internal/customers/cache/fullSubject/sanitize/sanitizeCachedFullSubject.ts b/server/src/internal/customers/cache/fullSubject/sanitize/sanitizeCachedFullSubject.ts index 6918deb1f..ec19f73f8 100644 --- a/server/src/internal/customers/cache/fullSubject/sanitize/sanitizeCachedFullSubject.ts +++ b/server/src/internal/customers/cache/fullSubject/sanitize/sanitizeCachedFullSubject.ts @@ -13,8 +13,20 @@ export const sanitizeCachedFullSubject = ({ cachedFullSubject, }: { cachedFullSubject: CachedFullSubject; -}): CachedFullSubject => - normalizeFromSchema({ +}): CachedFullSubject => { + const normalized = normalizeFromSchema({ schema: CachedFullSubjectSchema, data: cachedFullSubject, }); + + // Safeguard for new product fields: Upstash Lua cjson collapses `{}` to `[]`, + // and pre-existing cache entries may not have these fields at all. + for (const product of normalized.products ?? []) { + const productAsRecord = product as { config?: unknown }; + if (!productAsRecord.config || Array.isArray(productAsRecord.config)) { + productAsRecord.config = {}; + } + } + + return normalized; +}; diff --git a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getCachedFullCustomer.ts b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getCachedFullCustomer.ts index f591aefae..e200f217d 100644 --- a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getCachedFullCustomer.ts +++ b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getCachedFullCustomer.ts @@ -196,6 +196,16 @@ export const getCachedFullCustomer = async ({ fullCustomer.send_email_receipts = false; } + // Safeguard for new product fields: Upstash Lua cjson collapses `{}` to `[]`, + // and pre-existing cache entries may not have these fields at all. + for (const cusProduct of fullCustomer.customer_products ?? []) { + if (!cusProduct.product) continue; + const product = cusProduct.product as { config?: unknown }; + if (!product.config || Array.isArray(product.config)) { + product.config = {}; + } + } + fullCustomer.invoices = deduplicateFullCustomerInvoices(fullCustomer); fullCustomer.customer_products = filterExpiredCustomerProducts(fullCustomer); diff --git a/server/src/utils/cacheUtils/normalizeFromSchema.ts b/server/src/utils/cacheUtils/normalizeFromSchema.ts index 0571cf41f..3b79a1dbf 100644 --- a/server/src/utils/cacheUtils/normalizeFromSchema.ts +++ b/server/src/utils/cacheUtils/normalizeFromSchema.ts @@ -109,13 +109,21 @@ export const normalizeFromSchema = ({ } if (type === "object") { - if (!data || typeof data !== "object" || Array.isArray(data)) { + // Upstash Lua cjson collapses empty `{}` to `[]`; treat as empty object + // so nested defaults still get applied on round-trip. + const objectData = + Array.isArray(data) && data.length === 0 ? {} : data; + if ( + !objectData || + typeof objectData !== "object" || + Array.isArray(objectData) + ) { return data as T; } const shape = (unwrapped as any)._def.shape; const normalized: Record = { - ...(data as Record), + ...(objectData as Record), }; for (const key in shape) { From 444a4d8f1e70fe5147899b56d580e5c94c36d511 Mon Sep 17 00:00:00 2001 From: Charlie Lamb Date: Tue, 28 Apr 2026 10:16:03 +0100 Subject: [PATCH 4/5] fix: cus cache missing prices --- .../actions/getCachedFullSubject.ts | 17 ++++ .../partial/getCachedPartialFullSubject.ts | 23 +++++ .../fullSubject/fullSubjectCacheModel.ts | 12 ++- .../get-customer-cache-subscription.test.ts | 55 ++++++++++++ .../full-subject-cache-model.test.ts | 87 +++++++++++++++++++ 5 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 server/tests/integration/crud/customers/get-customer-cache-subscription.test.ts diff --git a/server/src/internal/customers/cache/fullSubject/actions/getCachedFullSubject.ts b/server/src/internal/customers/cache/fullSubject/actions/getCachedFullSubject.ts index a1f872630..11d0efe42 100644 --- a/server/src/internal/customers/cache/fullSubject/actions/getCachedFullSubject.ts +++ b/server/src/internal/customers/cache/fullSubject/actions/getCachedFullSubject.ts @@ -12,6 +12,7 @@ import { FULL_SUBJECT_EPOCH_TTL_SECONDS } from "../config/fullSubjectCacheConfig import { type CachedFullSubject, cachedFullSubjectToNormalized, + FULL_SUBJECT_CACHE_SCHEMA_VERSION, } from "../fullSubjectCacheModel.js"; import { sanitizeCachedFullSubject } from "../sanitize/index.js"; import { invalidateCachedFullSubject } from "./invalidate/invalidateFullSubject.js"; @@ -121,6 +122,22 @@ export const getCachedFullSubject = async ({ }; } + if (cached._schemaVersion !== FULL_SUBJECT_CACHE_SCHEMA_VERSION) { + logger.warn( + `[getCachedFullSubject] Stale subject schema version for ${customerId}${entityId ? `:${entityId}` : ""}, cached=${cached._schemaVersion ?? "missing"}, current=${FULL_SUBJECT_CACHE_SCHEMA_VERSION}, source: ${source}`, + ); + await invalidateCachedFullSubjectExact({ + ctx, + customerId, + entityId, + source: "stale-subject-schema-version", + }); + return { + fullSubject: undefined, + subjectViewEpoch: currentSubjectViewEpoch, + }; + } + const rolloutSnapshot = getFullSubjectRolloutSnapshot({ ctx }); if ( rolloutSnapshot && diff --git a/server/src/internal/customers/cache/fullSubject/actions/partial/getCachedPartialFullSubject.ts b/server/src/internal/customers/cache/fullSubject/actions/partial/getCachedPartialFullSubject.ts index 2b9ee23b2..678381a96 100644 --- a/server/src/internal/customers/cache/fullSubject/actions/partial/getCachedPartialFullSubject.ts +++ b/server/src/internal/customers/cache/fullSubject/actions/partial/getCachedPartialFullSubject.ts @@ -14,6 +14,7 @@ import { filterNormalizedFullSubjectByFeatureIds } from "../../filterFullSubject import { type CachedFullSubject, cachedFullSubjectToNormalized, + FULL_SUBJECT_CACHE_SCHEMA_VERSION, } from "../../fullSubjectCacheModel.js"; import { sanitizeCachedFullSubject } from "../../sanitize/index.js"; import { tryOrInvalidate } from "../../tryOrInvalidate.js"; @@ -141,6 +142,28 @@ export const getCachedPartialFullSubject = async ({ }; } + const schemaOk = await tryOrInvalidate({ + ctx, + operation: () => + cached._schemaVersion === FULL_SUBJECT_CACHE_SCHEMA_VERSION + ? true + : undefined, + invalidate: () => + invalidateCachedFullSubjectExact({ + ctx, + customerId, + entityId, + source: "partial-stale-subject-schema-version", + }), + warnMessage: `[getCachedPartialFullSubject] Stale subject schema version for ${subjectLabel}, cached=${cached._schemaVersion ?? "missing"}, current=${FULL_SUBJECT_CACHE_SCHEMA_VERSION}, source=${source}`, + }); + if (schemaOk === undefined) { + return { + fullSubject: undefined, + subjectViewEpoch: currentSubjectViewEpoch, + }; + } + const rolloutSnapshot = getFullSubjectRolloutSnapshot({ ctx }); const rolloutOk = await tryOrInvalidate({ ctx, diff --git a/server/src/internal/customers/cache/fullSubject/fullSubjectCacheModel.ts b/server/src/internal/customers/cache/fullSubject/fullSubjectCacheModel.ts index 5506b300a..91b6f3cd6 100644 --- a/server/src/internal/customers/cache/fullSubject/fullSubjectCacheModel.ts +++ b/server/src/internal/customers/cache/fullSubject/fullSubjectCacheModel.ts @@ -1,6 +1,7 @@ import { CusProductSchema, CustomerSchema, + CustomerPriceSchema, EntitlementWithFeatureSchema, EntityAggregationsSchema, EntitySchema, @@ -16,14 +17,17 @@ import { z } from "zod/v4"; export type CachedFullSubject = Omit< NormalizedFullSubject, - "customer_entitlements" | "customer_prices" + "customer_entitlements" > & { + _schemaVersion: number; _cachedAt: number; meteredFeatures: string[]; customerEntitlementIdsByFeatureId: Record; subjectViewEpoch: number; }; +export const FULL_SUBJECT_CACHE_SCHEMA_VERSION = 2; + /** * Schema mirror of `CachedFullSubject` used by the cache-hole-filling walker * ({@link normalizeFromSchema}) to locate nullable positions in cached @@ -45,6 +49,7 @@ export const CachedFullSubjectSchema = z.object({ entity: EntitySchema.optional(), customer_products: z.array(CusProductSchema), + customer_prices: z.array(CustomerPriceSchema), flags: z.record(z.string(), SubjectFlagSchema), products: z.array(ProductSchema), @@ -57,6 +62,7 @@ export const CachedFullSubjectSchema = z.object({ entity_aggregations: EntityAggregationsSchema.optional(), + _schemaVersion: z.number().optional(), _cachedAt: z.number(), meteredFeatures: z.array(z.string()), customerEntitlementIdsByFeatureId: z.record(z.string(), z.array(z.string())), @@ -102,6 +108,7 @@ export const normalizedToCachedFullSubject = ({ customer: normalized.customer, entity: normalized.entity, customer_products: normalized.customer_products, + customer_prices: normalized.customer_prices, flags: normalized.flags, products: normalized.products, entitlements: normalized.entitlements, @@ -110,6 +117,7 @@ export const normalizedToCachedFullSubject = ({ subscriptions: normalized.subscriptions, invoices: normalized.invoices, entity_aggregations: normalized.entity_aggregations, + _schemaVersion: FULL_SUBJECT_CACHE_SCHEMA_VERSION, _cachedAt: Date.now(), meteredFeatures, customerEntitlementIdsByFeatureId, @@ -134,7 +142,7 @@ export const cachedFullSubjectToNormalized = ({ entity: cached.entity, customer_products: cached.customer_products, customer_entitlements: customerEntitlements, - customer_prices: [], + customer_prices: cached.customer_prices, flags: cached.flags, products: cached.products, entitlements: cached.entitlements, diff --git a/server/tests/integration/crud/customers/get-customer-cache-subscription.test.ts b/server/tests/integration/crud/customers/get-customer-cache-subscription.test.ts new file mode 100644 index 000000000..402ff074b --- /dev/null +++ b/server/tests/integration/crud/customers/get-customer-cache-subscription.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from "bun:test"; +import { + type ApiCustomerV5, + ApiCustomerV5Schema, +} from "@shared/api/customers/apiCustomerV5"; +import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +test.concurrent(`${chalk.yellowBright("get-customer: cached recurring plan with one-off usage remains subscription")}`, async () => { + const customerId = "get-customer-mixed-recurring-oneoff-cache"; + const oneOffUsageItem = items.oneOffMessages({ + billingUnits: 100, + price: 10, + }); + const hobby = products.pro({ + id: "hobby", + items: [oneOffUsageItem], + }); + + const { autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [hobby] }), + ], + actions: [s.billing.attach({ productId: hobby.id })], + }); + + await autumnV2_2.customers.get(customerId, { + keepInternalFields: true, + }); + const cachedCustomer = await autumnV2_2.customers.get( + customerId, + { + keepInternalFields: true, + }, + ); + + ApiCustomerV5Schema.parse(cachedCustomer); + const subscription = cachedCustomer.subscriptions.find( + (subscription) => subscription.plan_id === hobby.id, + ); + + expect(subscription).toBeDefined(); + expect(subscription!.current_period_start).toBeNumber(); + expect(subscription!.current_period_end).toBeNumber(); + expect( + cachedCustomer.purchases.find((purchase) => purchase.plan_id === hobby.id), + ).toBeUndefined(); + + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); diff --git a/server/tests/unit/full-subject-cache/full-subject-cache-model.test.ts b/server/tests/unit/full-subject-cache/full-subject-cache-model.test.ts index a39e0eeff..1090a2d34 100644 --- a/server/tests/unit/full-subject-cache/full-subject-cache-model.test.ts +++ b/server/tests/unit/full-subject-cache/full-subject-cache-model.test.ts @@ -1,11 +1,16 @@ import { describe, expect, test } from "bun:test"; import { AppEnv, + BillingInterval, + isCustomerProductOneOff, type NormalizedFullSubject, + normalizedToFullSubject, + PriceType, SubjectType, } from "@autumn/shared"; import { cachedFullSubjectToNormalized, + FULL_SUBJECT_CACHE_SCHEMA_VERSION, normalizedToCachedFullSubject, } from "@/internal/customers/cache/fullSubject/fullSubjectCacheModel.js"; @@ -105,6 +110,65 @@ const buildNormalized = (): NormalizedFullSubject => entity_aggregations: undefined, }) as unknown as NormalizedFullSubject; +const buildMixedIntervalNormalized = (): NormalizedFullSubject => { + const normalized = buildNormalized(); + const [customerEntitlement] = normalized.customer_entitlements; + if (!customerEntitlement) throw new Error("expected test entitlement"); + const fixedPrice = { + id: "price_fixed", + config: { + type: PriceType.Fixed, + amount: 19, + interval: BillingInterval.Month, + }, + }; + const usagePrice = { + id: "price_usage", + config: { + type: PriceType.Usage, + interval: BillingInterval.OneOff, + usage_tiers: [{ to: "inf", amount: 9 }], + }, + }; + const fixedCustomerPrice = { + id: "cus_price_fixed", + price_id: "price_fixed", + customer_product_id: "cp_1", + }; + const usageCustomerPrice = { + id: "cus_price_usage", + price_id: "price_usage", + customer_product_id: "cp_1", + }; + + return { + ...normalized, + customer_products: [ + { + id: "cp_1", + internal_product_id: "prod_int_1", + free_trial_id: null, + }, + ], + customer_entitlements: [ + { + ...customerEntitlement, + customerPrice: { ...usageCustomerPrice, price: usagePrice }, + }, + ], + customer_prices: [fixedCustomerPrice, usageCustomerPrice], + flags: {}, + products: [ + { + internal_id: "prod_int_1", + id: "prod_1", + is_add_on: false, + }, + ], + prices: [fixedPrice, usagePrice], + } as unknown as NormalizedFullSubject; +}; + describe("fullSubject cache model", () => { test("stores non-balance data in the top-level subject", () => { const normalized = buildNormalized(); @@ -118,6 +182,7 @@ describe("fullSubject cache model", () => { expect(cached.customerEntitlementIdsByFeatureId).toEqual({ feat_1: ["cus_ent_1"], }); + expect(cached._schemaVersion).toBe(FULL_SUBJECT_CACHE_SCHEMA_VERSION); expect(cached._cachedAt).toBeTypeOf("number"); }); @@ -171,4 +236,26 @@ describe("fullSubject cache model", () => { ); expect(reconstructed.customer_prices).toEqual([]); }); + + test("preserves fixed prices without entitlements across cache roundtrip", () => { + const normalized = buildMixedIntervalNormalized(); + const cached = normalizedToCachedFullSubject({ + normalized, + subjectViewEpoch: 0, + }); + const reconstructed = cachedFullSubjectToNormalized({ + cached, + customerEntitlements: normalized.customer_entitlements, + }); + const fullSubject = normalizedToFullSubject({ normalized: reconstructed }); + const [customerProduct] = fullSubject.customer_products; + + expect(customerProduct).toBeDefined(); + expect( + customerProduct!.customer_prices.map((customerPrice) => + customerPrice.price_id, + ), + ).toEqual(["price_fixed", "price_usage"]); + expect(isCustomerProductOneOff(customerProduct)).toBe(false); + }); }); From 74a5aecd113a87208899bd53e563c7ac2553004e Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:23:24 +0100 Subject: [PATCH 5/5] =?UTF-8?q?test:=20=F0=9F=92=8D=20fuzz=20test=20cache?= =?UTF-8?q?=20sanitisation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../full-subject-cache-roundtrip.test.ts | 80 +++- .../sanitizeCachedPayloads.test.ts | 404 +++++++++++++++++- 2 files changed, 482 insertions(+), 2 deletions(-) diff --git a/server/tests/integration/db/full-subject-cache/full-subject-cache-roundtrip.test.ts b/server/tests/integration/db/full-subject-cache/full-subject-cache-roundtrip.test.ts index a241bf280..a37601ef7 100644 --- a/server/tests/integration/db/full-subject-cache/full-subject-cache-roundtrip.test.ts +++ b/server/tests/integration/db/full-subject-cache/full-subject-cache-roundtrip.test.ts @@ -1,9 +1,10 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { normalizedToFullSubject } from "@autumn/shared"; +import { AppEnv, normalizedToFullSubject } from "@autumn/shared"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; import chalk from "chalk"; import { getOrInitFullSubjectViewEpoch } from "@/internal/customers/cache/fullSubject/actions/invalidate/getOrInitFullSubjectViewEpoch.js"; import type { CachedFullSubject } from "@/internal/customers/cache/fullSubject/fullSubjectCacheModel.js"; +import { sanitizeCachedFullSubject } from "@/internal/customers/cache/fullSubject/sanitize/sanitizeCachedFullSubject.js"; import { buildFullSubjectKey, buildFullSubjectViewEpochKey, @@ -556,4 +557,81 @@ describe(`${chalk.yellowBright("fullSubject cache roundtrip")}`, () => { }, }); }); + + // ───────────────────────────────────────────────────────────────────────── + // Commit ce100dbf — cache sanitization regression + // + // Upstash's Lua cjson collapses empty `{}` to `[]` on round-trip. Without + // the sanitizer, a cached `product.config` written as `{}` comes back as + // `[]` and downstream consumers throw "Expected object, received array". + // ───────────────────────────────────────────────────────────────────────── + test(`${chalk.yellowBright("redis round-trip — cached product.config:[] is sanitized to {} on read")}`, async () => { + const planId = "plan_sanitize_redis_roundtrip"; + const cacheKey = `tests:cache-sanitize-roundtrip:${planId}:${Date.now()}`; + const malformedCacheEntry: unknown = { + subjectType: "customer", + customerId: "cus_sanitize_redis", + internalCustomerId: "cus_int_sanitize_redis", + _cachedAt: Date.now(), + subjectViewEpoch: 0, + meteredFeatures: [], + customerEntitlementIdsByFeatureId: {}, + customer: { + internal_id: "cus_int_sanitize_redis", + org_id: ctx.org.id, + env: AppEnv.Live, + created_at: 1, + }, + customer_products: [], + products: [ + { + id: planId, + internal_id: `ip_${planId}`, + name: planId, + group: `grp_${planId}`, + created_at: 1, + env: ctx.env, + org_id: ctx.org.id, + is_add_on: false, + is_default: false, + version: 1, + archived: false, + config: [], // <-- this is what Upstash hands back for `{}` + }, + ], + entitlements: [], + prices: [], + free_trials: [], + subscriptions: [], + invoices: [], + flags: {}, + }; + + try { + await ctx.redisV2.set(cacheKey, JSON.stringify(malformedCacheEntry)); + const raw = await ctx.redisV2.get(cacheKey); + expect(raw).toBeDefined(); + const parsed = JSON.parse(raw as string) as CachedFullSubject; + + // Pre-condition: the on-wire payload really does contain the bug + // shape we're guarding against. + expect( + Array.isArray( + (parsed.products[0] as unknown as { config: unknown }).config, + ), + ).toBe(true); + + const sanitized = sanitizeCachedFullSubject({ + cachedFullSubject: parsed, + }); + const product = sanitized.products[0] as unknown as { + config: { ignore_past_due: boolean }; + }; + + expect(Array.isArray(product.config)).toBe(false); + expect(product.config.ignore_past_due).toBe(false); + } finally { + await ctx.redisV2.del(cacheKey); + } + }); }); diff --git a/server/tests/unit/full-subject-cache/sanitizeCachedPayloads.test.ts b/server/tests/unit/full-subject-cache/sanitizeCachedPayloads.test.ts index fdd5f89cf..eeccf028a 100644 --- a/server/tests/unit/full-subject-cache/sanitizeCachedPayloads.test.ts +++ b/server/tests/unit/full-subject-cache/sanitizeCachedPayloads.test.ts @@ -2,14 +2,19 @@ import { describe, expect, test } from "bun:test"; import { type AggregatedFeatureBalanceSchema, AppEnv, + ProductSchema, type SubjectBalance, } from "@autumn/shared"; import { z } from "zod/v4"; -import type { CachedFullSubject } from "@/internal/customers/cache/fullSubject/fullSubjectCacheModel.js"; +import { + type CachedFullSubject, + CachedFullSubjectSchema, +} from "@/internal/customers/cache/fullSubject/fullSubjectCacheModel.js"; import { normalizeFromSchema } from "@/internal/customers/cache/fullSubject/sanitize/normalizeFromSchema.js"; import { sanitizeCachedAggregatedFeatureBalance } from "@/internal/customers/cache/fullSubject/sanitize/sanitizeCachedAggregatedFeatureBalance.js"; import { sanitizeCachedFullSubject } from "@/internal/customers/cache/fullSubject/sanitize/sanitizeCachedFullSubject.js"; import { sanitizeCachedSubjectBalance } from "@/internal/customers/cache/fullSubject/sanitize/sanitizeCachedSubjectBalance.js"; +import { normalizeFromSchema as normalizeFromSchemaCacheUtils } from "@/utils/cacheUtils/normalizeFromSchema.js"; describe("normalizeFromSchema (core walker)", () => { test("fills undefined at nullable position with null", () => { @@ -501,3 +506,400 @@ describe("sanitizeCachedAggregatedFeatureBalance", () => { expect(result.prepaid_grant_from_options).toBe(0); }); }); + +// ═══════════════════════════════════════════════════════════════════════════════ +// Cache sanitization regression — commit ce100dbf +// +// Upstash's Lua cjson collapses empty `{}` to `[]`, and pre-existing cache +// entries that pre-date a new schema field will be missing it entirely. The +// sanitizer must: +// 1. coerce empty arrays back to objects when the schema says "object" +// (so nested defaults like `ProductConfigSchema.ignore_past_due=false` +// are re-applied), +// 2. defensively coerce `product.config` to `{}` when it's an array or +// missing (belt-and-suspenders for downstream consumers), +// 3. never throw "Expected object, received array" for any nested object +// shape regardless of what fields are added in the future. +// ═══════════════════════════════════════════════════════════════════════════════ + +const buildBaseCachedFullSubjectForSanitize = (): CachedFullSubject => + ({ + subjectType: "customer", + customerId: "cus_sanitize", + internalCustomerId: "cus_int_sanitize", + _cachedAt: Date.now(), + subjectViewEpoch: 0, + meteredFeatures: [], + customerEntitlementIdsByFeatureId: {}, + customer: { + internal_id: "cus_int_sanitize", + org_id: "org_sanitize", + env: AppEnv.Live, + created_at: 1, + }, + customer_products: [], + products: [], + entitlements: [], + prices: [], + free_trials: [], + subscriptions: [], + invoices: [], + flags: {}, + }) as unknown as CachedFullSubject; + +const buildBaseProductForSanitize = (planId = "plan_sanitize") => + ({ + // Just enough fields to look like a Product; the sanitizer only + // inspects `config` for this regression. + id: planId, + internal_id: `ip_${planId}`, + name: planId, + group: `grp_${planId}`, + created_at: 1, + env: AppEnv.Live, + org_id: "org_sanitize", + is_add_on: false, + is_default: false, + version: 1, + archived: false, + }) as Record; + +describe("sanitizeCachedFullSubject — product.config (commit ce100dbf)", () => { + test("coerces product.config: [] -> {} and re-applies ignore_past_due default", () => { + // Simulates what Upstash hands back: ignore_past_due defaulted to {}, + // cjson encoded it as [], so on read we get `config: []`. The walker + // must rebuild it as `{ ignore_past_due: false }`. + const malformed = buildBaseCachedFullSubjectForSanitize() as unknown as Record< + string, + unknown + >; + malformed.products = [ + { + ...buildBaseProductForSanitize("plan_sanitize_empty_array_config"), + config: [], + }, + ]; + + const result = sanitizeCachedFullSubject({ + cachedFullSubject: malformed as unknown as CachedFullSubject, + }); + + const product = result.products[0] as unknown as { + config: { ignore_past_due?: boolean }; + }; + + // Layer 1 (walker): config is an object, not an array. + expect(Array.isArray(product.config)).toBe(false); + expect(product.config).toBeDefined(); + expect(typeof product.config).toBe("object"); + + // Layer 2 (walker default-application): nested ZodDefault hydrated. + // If this fails the walker isn't recursing into the rebuilt object. + expect(product.config.ignore_past_due).toBe(false); + }); + + test("fills missing product.config entirely (pre-field-existed cache entries)", () => { + // Pre-existing cache entries written before `config` existed simply + // don't have the field — the belt-and-suspenders block in + // sanitizeCachedFullSubject must inject {}. + const malformed = buildBaseCachedFullSubjectForSanitize() as unknown as Record< + string, + unknown + >; + malformed.products = [buildBaseProductForSanitize("plan_sanitize_missing_config")]; + + const result = sanitizeCachedFullSubject({ + cachedFullSubject: malformed as unknown as CachedFullSubject, + }); + + const product = result.products[0] as unknown as { + config: Record; + }; + expect(product.config).toBeDefined(); + expect(Array.isArray(product.config)).toBe(false); + expect(typeof product.config).toBe("object"); + }); + + test("preserves explicit ignore_past_due=true (no over-correction)", () => { + // If Upstash cjson encoded `{ ignore_past_due: true }` faithfully (only + // fully-empty objects collapse), the value must survive untouched. + const malformed = buildBaseCachedFullSubjectForSanitize() as unknown as Record< + string, + unknown + >; + malformed.products = [ + { + ...buildBaseProductForSanitize("plan_sanitize_preserve_true"), + config: { ignore_past_due: true }, + }, + ]; + + const result = sanitizeCachedFullSubject({ + cachedFullSubject: malformed as unknown as CachedFullSubject, + }); + + const product = result.products[0] as unknown as { + config: { ignore_past_due: boolean }; + }; + expect(product.config.ignore_past_due).toBe(true); + }); +}); + +describe("normalizeFromSchema — empty-array-as-object regression (commit ce100dbf)", () => { + test("cacheUtils walker rebuilds ZodObject from empty array on nested products", () => { + // Direct exercise of the cacheUtils walker — used by + // getCachedFullCustomer. The structural fix from commit ce100dbf: + // nested `config: []` must become `config: {}` so downstream + // "Expected object, received array" errors stop firing. (This walker + // does NOT re-apply ZodDefault values; the belt-and-suspenders block + // in getCachedFullCustomer handles defaults for product.config.) + const TestSchema = z.object({ + products: z.array(ProductSchema), + }); + + const result = normalizeFromSchemaCacheUtils<{ + products: Array<{ config: unknown }>; + }>({ + schema: TestSchema as unknown as z.ZodTypeAny, + data: { + products: [ + { + ...buildBaseProductForSanitize("plan_sanitize_cacheutils"), + config: [], + }, + ], + }, + }); + + const product = result.products[0]!; + expect(Array.isArray(product.config)).toBe(false); + expect(typeof product.config).toBe("object"); + expect(product.config).not.toBeNull(); + }); + + test("full-subject walker rebuilds ZodObject from empty array on nested products", () => { + const result = normalizeFromSchema<{ + products: Array<{ config: { ignore_past_due: boolean } }>; + }>({ + schema: CachedFullSubjectSchema, + data: { + ...(buildBaseCachedFullSubjectForSanitize() as unknown as Record< + string, + unknown + >), + products: [ + { + ...buildBaseProductForSanitize("plan_sanitize_fullsubject"), + config: [], + }, + ], + }, + }); + + const product = result.products[0]!; + expect(Array.isArray(product.config)).toBe(false); + expect(product.config.ignore_past_due).toBe(false); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Generic fuzz: mutate every field of a sample subject across multiple +// "Upstash-style" corruption modes and assert sanitization always returns a +// stable, downstream-consumable shape (no thrown errors, no array-shaped +// values at object positions). This is the regression net for any future +// field added to ProductSchema or peer schemas — new fields get fuzzed for +// free on the next test run. +// ───────────────────────────────────────────────────────────────────────────── + +type SanitizerCorruptionMode = + | "object_to_empty_array" + | "array_to_empty_object" + | "drop_field" + | "set_null" + | "set_undefined"; + +const ALL_SANITIZER_CORRUPTION_MODES: SanitizerCorruptionMode[] = [ + "object_to_empty_array", + "array_to_empty_object", + "drop_field", + "set_null", + "set_undefined", +]; + +const corruptValueForSanitizer = ( + value: unknown, + mode: SanitizerCorruptionMode, +): unknown => { + switch (mode) { + case "object_to_empty_array": + // Only objects can collapse to [] in Upstash cjson. + return value && typeof value === "object" && !Array.isArray(value) + ? [] + : value; + case "array_to_empty_object": + return Array.isArray(value) ? {} : value; + case "drop_field": + return undefined; + case "set_null": + return null; + case "set_undefined": + return undefined; + } +}; + +/** + * Walk `data` shape-blind and return a new structure where every leaf has had + * `corruptValueForSanitizer(_, mode)` applied. Recurses into objects and + * arrays so nested fields (like `products[*].config`) get hit. Generic — never + * references `config` or `ignore_past_due` directly. + */ +const corruptAllFieldsForSanitizer = ( + data: unknown, + mode: SanitizerCorruptionMode, + depth = 0, +): unknown => { + if (depth > 4) return data; // safety against pathological cycles + if (Array.isArray(data)) { + return data.map((item) => + corruptAllFieldsForSanitizer(item, mode, depth + 1), + ); + } + if (data && typeof data === "object") { + const out: Record = {}; + for (const [key, value] of Object.entries(data)) { + const recursed = corruptAllFieldsForSanitizer(value, mode, depth + 1); + out[key] = corruptValueForSanitizer(recursed, mode); + } + return out; + } + return data; +}; + +const assertNoArrayShapedObjects = ( + value: unknown, + schema: z.ZodTypeAny, + path = "$", +): void => { + // Unwrap optional/nullable/default chains to the inner shape-bearing schema. + let unwrapped: z.ZodTypeAny = schema; + while ( + unwrapped instanceof z.ZodOptional || + unwrapped instanceof z.ZodNullable || + unwrapped instanceof z.ZodDefault + ) { + unwrapped = (unwrapped as unknown as { _def: { innerType: z.ZodTypeAny } }) + ._def.innerType; + } + + if (unwrapped instanceof z.ZodObject) { + // Critical assertion: the object position must NOT carry an Array + // payload — that's exactly the "Expected object, received array" + // breakage commit ce100dbf is fixing. + if (Array.isArray(value)) { + throw new Error( + `Sanitizer left an array at object position ${path} (caller would see "Expected object, received array")`, + ); + } + if (value && typeof value === "object") { + const shape = (unwrapped as unknown as { _def: { shape: Record } })._def.shape; + for (const [key, childSchema] of Object.entries(shape)) { + assertNoArrayShapedObjects( + (value as Record)[key], + childSchema, + `${path}.${key}`, + ); + } + } + return; + } + + if (unwrapped instanceof z.ZodArray) { + if (Array.isArray(value)) { + const element = (unwrapped as unknown as { _def: { element: z.ZodTypeAny } })._def.element; + value.forEach((item, idx) => + assertNoArrayShapedObjects(item, element, `${path}[${idx}]`), + ); + } + return; + } +}; + +describe("sanitizeCachedFullSubject — generic fuzz (regression net for new fields)", () => { + test("every Upstash corruption mode is recovered without leaving array payloads at object positions", () => { + // Build a "fully-populated" subject so the walker has every nested + // shape to traverse. New fields on any nested schema get fuzzed for + // free on the next test run. + const populated = buildBaseCachedFullSubjectForSanitize() as unknown as Record< + string, + unknown + >; + populated.products = [ + { + ...buildBaseProductForSanitize("plan_sanitize_fuzz_1"), + config: { ignore_past_due: true }, + }, + { + ...buildBaseProductForSanitize("plan_sanitize_fuzz_2"), + config: { ignore_past_due: false }, + }, + ]; + + for (const mode of ALL_SANITIZER_CORRUPTION_MODES) { + // We corrupt only the *interior* of products[i] — the field-shape + // bug commit ce100dbf addresses. The top-level container shape + // (products is an array, the subject is an object, etc.) is what + // the cache layer guarantees on write, so corrupting it is + // outside the bug class. Mutating each product's interior is + // sufficient to fuzz every nested field of ProductSchema — + // including `config` and any future field added to it. + const corruptedProducts = ( + populated.products as Array> + ).map((p, i) => { + const corruptedProduct = corruptAllFieldsForSanitizer( + p, + mode, + ) as Record; + // Restore the structural identifier fields so the sanitizer + // has a stable record to work on. (The bug isn't about + // missing IDs.) + return { + ...corruptedProduct, + id: `plan_sanitize_fuzz_${i + 1}_${mode}`, + internal_id: `ip_plan_sanitize_fuzz_${i + 1}_${mode}`, + }; + }); + + const corrupted: Record = { + ...populated, + products: corruptedProducts, + }; + + let sanitized: CachedFullSubject; + expect(() => { + sanitized = sanitizeCachedFullSubject({ + cachedFullSubject: corrupted as unknown as CachedFullSubject, + }); + }).not.toThrow(); + + // Walk the full schema and prove no object position is left holding + // an array payload (the exact symptom of the bug). + expect(() => + assertNoArrayShapedObjects( + sanitized!, + CachedFullSubjectSchema as unknown as z.ZodTypeAny, + ), + ).not.toThrow(); + + // Spot-check the specific known target of commit ce100dbf: + // `product.config` is always an object after sanitization, + // regardless of corruption mode. + for (const product of (sanitized!.products ?? []) as Array<{ + config: unknown; + }>) { + expect(Array.isArray(product.config)).toBe(false); + expect(typeof product.config).toBe("object"); + expect(product.config).not.toBeNull(); + } + } + }); +});