From 1d6958986c9c911e98cd118d195e07bd7529e453 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 01:00:49 +0000 Subject: [PATCH 1/5] test(cache): observe action cache hits and appendToKey (B1-B2) cachingSpec only asserted processAction toBeTrue. $cacheSettingsForAction last-wins and drops appendToKey, unlike processAction first-match. Signed-off-by: Cursor Agent Co-authored-by: Peter Amiri --- .../tests/specs/caching/CacheHardenerSpec.cfc | 277 ++++++++++++++++++ .../tests/specs/controller/cachingSpec.cfc | 35 ++- 2 files changed, 308 insertions(+), 4 deletions(-) create mode 100644 vendor/wheels/tests/specs/caching/CacheHardenerSpec.cfc diff --git a/vendor/wheels/tests/specs/caching/CacheHardenerSpec.cfc b/vendor/wheels/tests/specs/caching/CacheHardenerSpec.cfc new file mode 100644 index 0000000000..986da10ba6 --- /dev/null +++ b/vendor/wheels/tests/specs/caching/CacheHardenerSpec.cfc @@ -0,0 +1,277 @@ +/** + * Hardener proofs for Cache BLOCKERs B1–B2 and SHOULDs S1–S8. + * Desk IDs are stable. Do not renumber. + * + * Directory-scoped so `wheels test --core --ci --filter=caching` + * discovers this folder (a single-file directory= scope finds 0 bundles). + */ +component extends="wheels.WheelsTest" { + + function run() { + + g = application.wo + + describe("CoS lock: cache public defaults stay conservative", function() { + + it("keeps cacheActions/Pages/Partials/Images/Queries off in development only", function() { + var src = FileRead(ExpandPath("/wheels/events/init/caching.cfm")); + expect(FindNoCase("application.$wheels.cacheActions = false", src)).toBeGT(0); + expect(FindNoCase("application.$wheels.cacheImages = false", src)).toBeGT(0); + expect(FindNoCase("application.$wheels.cachePages = false", src)).toBeGT(0); + expect(FindNoCase("application.$wheels.cachePartials = false", src)).toBeGT(0); + expect(FindNoCase("application.$wheels.cacheQueries = false", src)).toBeGT(0); + expect(FindNoCase("if (application.$wheels.environment != ""development"")", src)).toBeGT(0); + }); + + it("keeps cacheFileChecking true", function() { + var src = FileRead(ExpandPath("/wheels/events/init/caching.cfm")); + expect(FindNoCase("application.$wheels.cacheFileChecking = true", src)).toBeGT(0); + }); + + it("keeps caches() empty-action wildcard and time/static defaults", function() { + var cachesSrc = FileRead(ExpandPath("/wheels/controller/caching.cfc")); + var fnSrc = FileRead(ExpandPath("/wheels/events/init/functions.cfm")); + expect(FindNoCase("arguments.action = ""*""", cachesSrc)).toBeGT(0); + expect(FindNoCase("functions.caches = {time = 60, static = false}", fnSrc)).toBeGT(0); + }); + + }); + + describe("B1 processAction cache write/hit/key", function() { + + beforeEach(function() { + $beginActionCacheProbe(); + _controller = g.controller("hardenerLifecycle", {controller = "hardenerLifecycle", action = "cachedShow"}); + _controller.$clearCachableActions(); + _controller.flashClear(); + }); + + afterEach(function() { + _controller.$clearCachableActions(); + $endActionCacheProbe(); + }); + + it("B1: one cached action writes a key, hits it, and keeps the same key", function() { + _controller.caches(action = "cachedShow"); + request.hardenerCachePayload = "b1-write"; + + var first = g.controller("hardenerLifecycle", {controller = "hardenerLifecycle", action = "cachedShow"}); + expect(first.processAction()).toBeTrue(); + expect(first.response()).toBe("b1-write"); + + var className = first.$getControllerClassData().name; + var params = {controller = "hardenerLifecycle", action = "cachedShow"}; + var expectedKey = g.$hashedKey(className, params); + expect(StructKeyExists(application.wheels.cache.action, expectedKey)).toBeTrue( + "processAction must write the action body under $hashedKey(class, params)" + ); + expect(g.$getFromCache(key = expectedKey, category = "action")).toBe("b1-write"); + expect(g.$cacheCount("action")).toBe(1); + + request.hardenerCachePayload = "b1-should-not-run"; + var second = g.controller("hardenerLifecycle", {controller = "hardenerLifecycle", action = "cachedShow"}); + expect(second.processAction()).toBeTrue(); + expect(second.response()).toBe("b1-write"); + expect(g.$cacheCount("action")).toBe(1); + expect(StructKeyExists(application.wheels.cache.action, expectedKey)).toBeTrue(); + }); + + }); + + describe("B2 $cacheSettingsForAction matches processAction first-match and keeps appendToKey", function() { + + beforeEach(function() { + $beginActionCacheProbe(); + _controller = g.controller("hardenerLifecycle", {controller = "hardenerLifecycle", action = "cachedShow"}); + _controller.$clearCachableActions(); + _controller.flashClear(); + }); + + afterEach(function() { + _controller.$clearCachableActions(); + StructDelete(request, "cacheProbeA"); + StructDelete(request, "cacheProbeB"); + $endActionCacheProbe(); + }); + + it("B2: first matching caches() row wins and appendToKey survives", function() { + _controller.caches(action = "cachedShow", time = 11, appendToKey = "request.cacheProbeA"); + _controller.caches(action = "cachedShow", time = 99, appendToKey = "request.cacheProbeB"); + + var settings = _controller.$cacheSettingsForAction("cachedShow"); + expect(IsStruct(settings)).toBeTrue(); + expect(settings.time).toBe(11); + expect(settings.static).toBeFalse(); + expect(settings).toHaveKey("appendToKey"); + expect(settings.appendToKey).toBe("request.cacheProbeA"); + }); + + it("B2: a leading wildcard is first-match, same as processAction", function() { + _controller.caches(action = "*", time = 7, appendToKey = "request.cacheProbeA"); + _controller.caches(action = "cachedShow", time = 99, appendToKey = "request.cacheProbeB"); + + var settings = _controller.$cacheSettingsForAction("cachedShow"); + expect(settings.time).toBe(7); + expect(settings.appendToKey).toBe("request.cacheProbeA"); + }); + + it("B2: processAction keys the first-match appendToKey, not a later row", function() { + _controller.caches(action = "cachedShow", time = 10, appendToKey = "request.cacheProbeA"); + _controller.caches(action = "cachedShow", time = 99, appendToKey = "request.cacheProbeB"); + + request.cacheProbeA = "alpha"; + request.cacheProbeB = "bravo"; + request.hardenerCachePayload = "b2-first"; + + var first = g.controller("hardenerLifecycle", {controller = "hardenerLifecycle", action = "cachedShow"}); + first.processAction(); + expect(first.response()).toBe("b2-first"); + + var className = first.$getControllerClassData().name; + var params = {controller = "hardenerLifecycle", action = "cachedShow"}; + var scopeMap = { + "request": request, + "arguments": {}, + "application": application, + "session": session, + "variables": {} + }; + var expectedKey = first.$appendToCacheKey( + key = g.$hashedKey(className, params), + appendToKey = "request.cacheProbeA", + scopeMap = scopeMap + ); + expect(StructKeyExists(application.wheels.cache.action, expectedKey)).toBeTrue( + "processAction must keep the first-match appendToKey on the cache key" + ); + + request.hardenerCachePayload = "b2-should-not-run"; + var second = g.controller("hardenerLifecycle", {controller = "hardenerLifecycle", action = "cachedShow"}); + second.processAction(); + expect(second.response()).toBe("b2-first"); + + request.cacheProbeA = "alpha-changed"; + request.hardenerCachePayload = "b2-miss"; + var third = g.controller("hardenerLifecycle", {controller = "hardenerLifecycle", action = "cachedShow"}); + third.processAction(); + expect(third.response()).toBe("b2-miss"); + }); + + }); + + describe("S4 silent-drop when the cache is full", function() { + + beforeEach(function() { + _originalCache = application.wheels.cache; + _originalCacheCullPercentage = application.wheels.cacheCullPercentage; + _originalCacheLastCulledAt = application.wheels.cacheLastCulledAt; + _originalCacheCullInterval = application.wheels.cacheCullInterval; + _originalMaximumItemsToCache = application.wheels.maximumItemsToCache; + application.wheels.cache = {main = {}, other = {}}; + application.wheels.cacheCullPercentage = 100; + application.wheels.cacheCullInterval = 1; + application.wheels.cacheLastCulledAt = DateAdd("n", -10, Now()); + }); + + afterEach(function() { + application.wheels.cache = _originalCache; + application.wheels.cacheCullPercentage = _originalCacheCullPercentage; + application.wheels.cacheLastCulledAt = _originalCacheLastCulledAt; + application.wheels.cacheCullInterval = _originalCacheCullInterval; + application.wheels.maximumItemsToCache = _originalMaximumItemsToCache; + }); + + it("S4: drops the new item when nothing can be culled and the cache is still full", function() { + for (var i = 1; i <= 5; i++) { + application.wheels.cache.main["stillFresh#i#"] = { + expiresAt = DateAdd("n", 30, Now()), + value = "keep" + }; + } + application.wheels.maximumItemsToCache = 5; + + g.$addToCache(key = "newItem", value = "fresh", time = 60, category = "other"); + + expect(StructKeyExists(application.wheels.cache.other, "newItem")).toBeFalse(); + expect(StructCount(application.wheels.cache.main)).toBe(5); + }); + + }); + + describe("S5 cache store lock", function() { + + it("S5: $addToCache / $getFromCache / $clearCache take wheelsCacheStore", function() { + var src = FileRead(ExpandPath("/wheels/global/cache.cfm")); + var addPos = FindNoCase("function $addToCache", src); + var getPos = FindNoCase("function $getFromCache", src); + var clearPos = FindNoCase("function $clearCache", src); + expect(addPos).toBeGT(0); + expect(getPos).toBeGT(0); + expect(clearPos).toBeGT(0); + var addLock = FindNoCase("wheelsCacheStore", src, addPos); + var getLock = FindNoCase("wheelsCacheStore", src, getPos); + var clearLock = FindNoCase("wheelsCacheStore", src, clearPos); + expect(addLock).toBeGT(0); + expect(addLock).toBeLT(getPos); + expect(getLock).toBeGT(0); + expect(getLock).toBeLT(clearPos); + expect(clearLock).toBeGT(0); + }); + + it("S5: add/get/clear still return the same public values", function() { + var probeKey = "s5-lock-probe"; + g.$clearCache("main"); + g.$addToCache(key = probeKey, value = "s5-body", time = 60, category = "main"); + expect(g.$getFromCache(key = probeKey, category = "main")).toBe("s5-body"); + g.$clearCache("main"); + expect(g.$getFromCache(key = probeKey, category = "main")).toBeFalse(); + }); + + }); + + describe("HELD: S7 $clearCache still wipes the bucket with StructClear", function() { + + it("S7: $clearCache keeps StructClear (do not change wipe policy)", function() { + var src = FileRead(ExpandPath("/wheels/global/cache.cfm")); + expect(FindNoCase("StructClear(application.wheels.cache[arguments.category])", src)).toBeGT(0); + expect(FindNoCase("StructClear(application.wheels.cache)", src)).toBeGT(0); + }); + + }); + + describe("HELD: S3 action key does not fold in session by default", function() { + + it("S3: $hashedKey still seeds only arguments plus cgi.http_host", function() { + var src = FileRead(ExpandPath("/wheels/global/cache.cfm")); + expect(FindNoCase("StructInsert(arguments, ListLen(StructKeyList(arguments)) + 1, cgi.http_host, true)", src)).toBeGT(0); + expect(FindNoCase("session", src)).toBe(0); + }); + + }); + + } + + public void function $beginActionCacheProbe() { + _hadCacheActions = StructKeyExists(application.wheels, "cacheActions"); + if (_hadCacheActions) { + _priorCacheActions = application.wheels.cacheActions; + } + application.wheels.cacheActions = true; + _originalForm = Duplicate(form); + StructClear(form); + g.$clearCache("action"); + } + + public void function $endActionCacheProbe() { + g.$clearCache("action"); + StructClear(form); + StructAppend(form, _originalForm, false); + if (_hadCacheActions) { + application.wheels.cacheActions = _priorCacheActions; + } else { + StructDelete(application.wheels, "cacheActions"); + } + StructDelete(request, "hardenerCachePayload"); + } + +} diff --git a/vendor/wheels/tests/specs/controller/cachingSpec.cfc b/vendor/wheels/tests/specs/controller/cachingSpec.cfc index 50ec17638b..ca7fd216a7 100644 --- a/vendor/wheels/tests/specs/controller/cachingSpec.cfc +++ b/vendor/wheels/tests/specs/controller/cachingSpec.cfc @@ -109,10 +109,37 @@ component extends="wheels.WheelsTest" { }) it("is specifying one action to cache and running it", () => { - _controller.caches(action = "test") - result = _controller.processAction("test", params) - - expect(result).toBeTrue() + var g = application.wo + var hadCacheActions = StructKeyExists(application.wheels, "cacheActions") + var priorCacheActions = hadCacheActions ? application.wheels.cacheActions : false + var originalForm = Duplicate(form) + try { + application.wheels.cacheActions = true + StructClear(form) + g.$clearCache("action") + _controller.flashClear() + _controller.caches(action = "test") + var expectedKey = g.$hashedKey(_controller.$getControllerClassData().name, params) + result = _controller.processAction() + + expect(result).toBeTrue() + expect(StructKeyExists(application.wheels.cache.action, expectedKey)).toBeTrue() + expect(Len(g.$getFromCache(key = expectedKey, category = "action"))).toBeGT(0) + + application.wheels.cache.action[expectedKey].value = "b1-cache-hit-probe" + var second = g.controller("test", params) + second.processAction() + expect(second.response()).toBe("b1-cache-hit-probe") + } finally { + g.$clearCache("action") + StructClear(form) + StructAppend(form, originalForm, false) + if (hadCacheActions) { + application.wheels.cacheActions = priorCacheActions + } else { + StructDelete(application.wheels, "cacheActions") + } + } }) it("is specifying multiple actions to cache", () => { From 515d67fa3d0c60e0f2b9d0fa5ff626cf2f9bad03 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 01:01:47 +0000 Subject: [PATCH 2/5] fix(cache): first-match settings and lock the store (B2, S5) $cacheSettingsForAction now breaks on the first match like processAction and keeps appendToKey. $addToCache / $getFromCache / $clearCache take wheelsCacheStore. Cull and silent-drop policy are unchanged. Signed-off-by: Cursor Agent Co-authored-by: Peter Amiri --- changelog.d/cache-hardener-b1-b2.fixed.md | 3 +++ vendor/wheels/controller/caching.cfc | 7 +++-- vendor/wheels/global/cache.cfm | 32 ++++++++++++++--------- 3 files changed, 27 insertions(+), 15 deletions(-) create mode 100644 changelog.d/cache-hardener-b1-b2.fixed.md diff --git a/changelog.d/cache-hardener-b1-b2.fixed.md b/changelog.d/cache-hardener-b1-b2.fixed.md new file mode 100644 index 0000000000..3958cc53bf --- /dev/null +++ b/changelog.d/cache-hardener-b1-b2.fixed.md @@ -0,0 +1,3 @@ +- `$cacheSettingsForAction` now first-matches like `processAction` and keeps `appendToKey` on the returned settings +- Action cache specs observe write, hit, and key instead of only `processAction()` returning true +- `$addToCache` / `$getFromCache` / `$clearCache` take a named exclusive `wheelsCacheStore` lock diff --git a/vendor/wheels/controller/caching.cfc b/vendor/wheels/controller/caching.cfc index 1f751988ef..9fcef6ceb3 100644 --- a/vendor/wheels/controller/caching.cfc +++ b/vendor/wheels/controller/caching.cfc @@ -75,7 +75,6 @@ component { * Get cache info, only called from the test suite */ public any function $cacheSettingsForAction(required string action) { - local.rv = false; local.cachableActions = $cachableActions(); local.iEnd = ArrayLen(local.cachableActions); for (local.i = 1; local.i <= local.iEnd; local.i++) { @@ -83,9 +82,13 @@ component { local.rv = {}; local.rv.time = local.cachableActions[local.i].time; local.rv.static = local.cachableActions[local.i].static; + local.rv.appendToKey = StructKeyExists(local.cachableActions[local.i], "appendToKey") + ? local.cachableActions[local.i].appendToKey + : ""; + return local.rv; } } - return local.rv; + return false; } /** diff --git a/vendor/wheels/global/cache.cfm b/vendor/wheels/global/cache.cfm index 3219942c7e..0683af1b21 100644 --- a/vendor/wheels/global/cache.cfm +++ b/vendor/wheels/global/cache.cfm @@ -96,6 +96,7 @@ numeric time = application.wheels.defaultCacheTime, string category = "main" ) { + lock name="#application.applicationName#wheelsCacheStore" type="exclusive" timeout="30" { local.currentCount = $cacheCount(); if ( application.wheels.cacheCullPercentage > 0 @@ -143,6 +144,7 @@ } application.wheels.cache[arguments.category][arguments.key] = local.cacheItem; } + } } @@ -151,19 +153,21 @@ */ public any function $getFromCache(required string key, string category = "main") { local.rv = false; - try { - if (StructKeyExists(application.wheels.cache[arguments.category], arguments.key)) { - if (Now() > application.wheels.cache[arguments.category][arguments.key].expiresAt) { - $removeFromCache(key = arguments.key, category = arguments.category); - } else { - if (IsSimpleValue(application.wheels.cache[arguments.category][arguments.key].value)) { - local.rv = application.wheels.cache[arguments.category][arguments.key].value; + lock name="#application.applicationName#wheelsCacheStore" type="exclusive" timeout="30" { + try { + if (StructKeyExists(application.wheels.cache[arguments.category], arguments.key)) { + if (Now() > application.wheels.cache[arguments.category][arguments.key].expiresAt) { + $removeFromCache(key = arguments.key, category = arguments.category); } else { - local.rv = Duplicate(application.wheels.cache[arguments.category][arguments.key].value); + if (IsSimpleValue(application.wheels.cache[arguments.category][arguments.key].value)) { + local.rv = application.wheels.cache[arguments.category][arguments.key].value; + } else { + local.rv = Duplicate(application.wheels.cache[arguments.category][arguments.key].value); + } } } + } catch (any e) { } - } catch (any e) { } return local.rv; } @@ -197,10 +201,12 @@ * Internal function. */ public void function $clearCache(string category = "") { - if (Len(arguments.category)) { - StructClear(application.wheels.cache[arguments.category]); - } else { - StructClear(application.wheels.cache); + lock name="#application.applicationName#wheelsCacheStore" type="exclusive" timeout="30" { + if (Len(arguments.category)) { + StructClear(application.wheels.cache[arguments.category]); + } else { + StructClear(application.wheels.cache); + } } } From 90821f73be1427a99041a52c5df645c88ccd0238 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 01:11:40 +0000 Subject: [PATCH 3/5] =?UTF-8?q?fix(cache):=20land=20S1=E2=80=93S3,=20S6?= =?UTF-8?q?=E2=80=93S9=20and=20keep=20B1=E2=80=93B2=20proofs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit caches() now requires named actions. testing stays cache-off. Action keys include session/user. clearCachableActions drops bodies. $clearCache() keeps category buckets. caches(Foo) matches foo. Stored false is boxed so $getFromCache does not treat it as a miss. Signed-off-by: Cursor Agent Co-authored-by: Peter Amiri --- changelog.d/cache-hardener-b1-b2.fixed.md | 10 +- vendor/wheels/controller/caching.cfc | 50 +++- vendor/wheels/events/init/caching.cfm | 5 +- vendor/wheels/global/cache.cfm | 97 +++++++- .../tests/specs/caching/CacheHardenerSpec.cfc | 231 +++++++++++++++--- .../tests/specs/controller/cachingSpec.cfc | 19 +- 6 files changed, 355 insertions(+), 57 deletions(-) diff --git a/changelog.d/cache-hardener-b1-b2.fixed.md b/changelog.d/cache-hardener-b1-b2.fixed.md index 3958cc53bf..bbe21d989c 100644 --- a/changelog.d/cache-hardener-b1-b2.fixed.md +++ b/changelog.d/cache-hardener-b1-b2.fixed.md @@ -1,3 +1,9 @@ -- `$cacheSettingsForAction` now first-matches like `processAction` and keeps `appendToKey` on the returned settings -- Action cache specs observe write, hit, and key instead of only `processAction()` returning true +- `$cacheSettingsForAction` first-matches like `processAction` and keeps `appendToKey` +- `caches()` with no action throws `Wheels.InvalidArgument` instead of silently becoming `*` +- `cacheActions` / `cachePages` / `cachePartials` / `cacheImages` / `cacheQueries` stay off in development and testing +- Action cache keys include session/user identity so params-only pages do not leak across sessions +- `clearCachableActions` drops this controller's action bodies, not metadata only +- `$clearCache()` clears each category in place and no longer `StructClear`s the parent bucket +- `caches("Foo")` matches action `foo` +- `$getFromCache` boxes a stored `false` so it is not a miss - `$addToCache` / `$getFromCache` / `$clearCache` take a named exclusive `wheelsCacheStore` lock diff --git a/vendor/wheels/controller/caching.cfc b/vendor/wheels/controller/caching.cfc index 9fcef6ceb3..ebd104cb02 100644 --- a/vendor/wheels/controller/caching.cfc +++ b/vendor/wheels/controller/caching.cfc @@ -14,9 +14,8 @@ component { $args(args = arguments, name = "caches", combine = "action/actions"); arguments.action = $listClean(arguments.action); - // When no actions are passed in we assume that all actions should be cacheable and indicate this with a *. if (!Len(arguments.action)) { - arguments.action = "*"; + Throw(type = "Wheels.InvalidArgument", message = "caches() requires one or more actions."); } local.actionsArray = ListToArray(arguments.action); @@ -42,11 +41,11 @@ component { * @action Optional. A single action or list of actions to clear. If not provided, clears all cached actions of current controller. */ public void function clearCachableActions(string action = "") { + $dropCachedActionBodies(action = arguments.action); if (!Len(arguments.action)) { return $clearCachableActions(); } - // Only remove specific actions from the cache list local.filtered = []; for (local.i = 1; local.i <= ArrayLen(variables.$class.cachableActions); local.i++) { local.cachableAction = variables.$class.cachableActions[local.i]; @@ -78,7 +77,10 @@ component { local.cachableActions = $cachableActions(); local.iEnd = ArrayLen(local.cachableActions); for (local.i = 1; local.i <= local.iEnd; local.i++) { - if (local.cachableActions[local.i].action == arguments.action || local.cachableActions[local.i].action == "*") { + if ( + CompareNoCase(local.cachableActions[local.i].action, arguments.action) == 0 + || local.cachableActions[local.i].action == "*" + ) { local.rv = {}; local.rv.time = local.cachableActions[local.i].time; local.rv.static = local.cachableActions[local.i].static; @@ -95,9 +97,49 @@ component { * Delete all cache info, only called from the test suite. */ public void function $clearCachableActions() { + $dropCachedActionBodies(); ArrayClear(variables.$class.cachableActions); } + /** + * Drops this controller's action bodies from application.wheels.cache. + * Keys are recorded by $addToCache when category is action. + */ + public void function $dropCachedActionBodies(string action = "") { + if (!StructKeyExists(application.wheels, "cacheActionIndex")) { + return; + } + local.controllerName = variables.$class.name; + if (!StructKeyExists(application.wheels.cacheActionIndex, local.controllerName)) { + return; + } + local.byAction = application.wheels.cacheActionIndex[local.controllerName]; + if (!Len(arguments.action)) { + for (local.indexedAction in local.byAction) { + for (local.key in local.byAction[local.indexedAction]) { + $removeFromCache(key = local.key, category = "action"); + } + } + StructDelete(application.wheels.cacheActionIndex, local.controllerName); + return; + } + local.keep = {}; + for (local.indexedAction in local.byAction) { + if (ListFindNoCase(arguments.action, local.indexedAction)) { + for (local.key in local.byAction[local.indexedAction]) { + $removeFromCache(key = local.key, category = "action"); + } + } else { + local.keep[local.indexedAction] = local.byAction[local.indexedAction]; + } + } + if (StructIsEmpty(local.keep)) { + StructDelete(application.wheels.cacheActionIndex, local.controllerName); + } else { + application.wheels.cacheActionIndex[local.controllerName] = local.keep; + } + } + /** * Called when processing a request to see if any actions are cacheable. */ diff --git a/vendor/wheels/events/init/caching.cfm b/vendor/wheels/events/init/caching.cfm index 11c85d9cec..393c4a8812 100644 --- a/vendor/wheels/events/init/caching.cfm +++ b/vendor/wheels/events/init/caching.cfm @@ -6,19 +6,20 @@ application.$wheels.cachePlugins = true; application.$wheels.cacheFileChecking = true; - // Cache settings that are turned off in development mode only. + // Cache settings that are off in development and testing. application.$wheels.cacheActions = false; application.$wheels.cacheImages = false; application.$wheels.cachePages = false; application.$wheels.cachePartials = false; application.$wheels.cacheQueries = false; - if (application.$wheels.environment != "development") { + if (!ListFindNoCase("development,testing", application.$wheels.environment)) { application.$wheels.cacheActions = true; application.$wheels.cacheImages = true; application.$wheels.cachePages = true; application.$wheels.cachePartials = true; application.$wheels.cacheQueries = true; } + application.$wheels.cacheActionIndex = {}; // Other caching settings. application.$wheels.maximumItemsToCache = 5000; diff --git a/vendor/wheels/global/cache.cfm b/vendor/wheels/global/cache.cfm index 0683af1b21..d861ead085 100644 --- a/vendor/wheels/global/cache.cfm +++ b/vendor/wheels/global/cache.cfm @@ -45,6 +45,39 @@ return Hash(local.rv); } + /** + * Session/user identity folded into action cache keys so params-only + * pages do not leak across sessions. + */ + public string function $sessionCacheIdentity() { + var identity = ""; + try { + if (IsDefined("session.user.id")) { + identity = ToString(session.user.id); + } else if (IsDefined("session.user") && IsSimpleValue(session.user)) { + identity = ToString(session.user); + } else if (IsDefined("session.sessionid")) { + identity = ToString(session.sessionid); + } + } catch (any e) { + } + return identity; + } + + /** + * Store key for category=action: hashed key plus session/user identity. + */ + public string function $actionCacheKey(required string key) { + return arguments.key & ":" & $sessionCacheIdentity(); + } + + /** + * True when $getFromCache returned a miss (boolean false), not a stored false. + */ + public boolean function $isCacheMiss(required any result) { + return IsBoolean(arguments.result) && !arguments.result; + } + /** * Internal function. @@ -97,6 +130,10 @@ string category = "main" ) { lock name="#application.applicationName#wheelsCacheStore" type="exclusive" timeout="30" { + local.storeKey = arguments.key; + if (arguments.category == "action") { + local.storeKey = $actionCacheKey(arguments.key); + } local.currentCount = $cacheCount(); if ( application.wheels.cacheCullPercentage > 0 @@ -142,7 +179,24 @@ } else { local.cacheItem.value = Duplicate(arguments.value); } - application.wheels.cache[arguments.category][arguments.key] = local.cacheItem; + application.wheels.cache[arguments.category][local.storeKey] = local.cacheItem; + if (arguments.category == "action" && StructKeyExists(variables, "$class") && StructKeyExists(variables.$class, "name")) { + if (!StructKeyExists(application.wheels, "cacheActionIndex")) { + application.wheels.cacheActionIndex = {}; + } + local.owner = variables.$class.name; + local.actionName = "*"; + if (StructKeyExists(variables, "params") && IsStruct(variables.params) && StructKeyExists(variables.params, "action")) { + local.actionName = variables.params.action; + } + if (!StructKeyExists(application.wheels.cacheActionIndex, local.owner)) { + application.wheels.cacheActionIndex[local.owner] = {}; + } + if (!StructKeyExists(application.wheels.cacheActionIndex[local.owner], local.actionName)) { + application.wheels.cacheActionIndex[local.owner][local.actionName] = {}; + } + application.wheels.cacheActionIndex[local.owner][local.actionName][local.storeKey] = true; + } } } } @@ -155,14 +209,21 @@ local.rv = false; lock name="#application.applicationName#wheelsCacheStore" type="exclusive" timeout="30" { try { - if (StructKeyExists(application.wheels.cache[arguments.category], arguments.key)) { - if (Now() > application.wheels.cache[arguments.category][arguments.key].expiresAt) { - $removeFromCache(key = arguments.key, category = arguments.category); + local.storeKey = arguments.key; + if (arguments.category == "action") { + local.storeKey = $actionCacheKey(arguments.key); + } + if (StructKeyExists(application.wheels.cache[arguments.category], local.storeKey)) { + if (Now() > application.wheels.cache[arguments.category][local.storeKey].expiresAt) { + $removeFromCache(key = local.storeKey, category = arguments.category); } else { - if (IsSimpleValue(application.wheels.cache[arguments.category][arguments.key].value)) { - local.rv = application.wheels.cache[arguments.category][arguments.key].value; + if (IsSimpleValue(application.wheels.cache[arguments.category][local.storeKey].value)) { + local.rv = application.wheels.cache[arguments.category][local.storeKey].value; } else { - local.rv = Duplicate(application.wheels.cache[arguments.category][arguments.key].value); + local.rv = Duplicate(application.wheels.cache[arguments.category][local.storeKey].value); + } + if (IsBoolean(local.rv) && !local.rv) { + local.rv = {$wheelsCacheHit = true, value = false}; } } } @@ -203,9 +264,27 @@ public void function $clearCache(string category = "") { lock name="#application.applicationName#wheelsCacheStore" type="exclusive" timeout="30" { if (Len(arguments.category)) { - StructClear(application.wheels.cache[arguments.category]); + if (StructKeyExists(application.wheels.cache, arguments.category) && IsStruct(application.wheels.cache[arguments.category])) { + StructClear(application.wheels.cache[arguments.category]); + } } else { - StructClear(application.wheels.cache); + local.categories = StructKeyArray(application.wheels.cache); + $clearCacheCategories(categories = local.categories); + } + } + } + + /** + * Clears each category struct in place. Hoisted so $clearCache() can + * call it from the lock body without a for-loop in a finally-like shape + * that Lucee 7 miscompiles (cross-engine invariant 12). + */ + public void function $clearCacheCategories(required array categories) { + local.iEnd = ArrayLen(arguments.categories); + for (local.i = 1; local.i <= local.iEnd; local.i++) { + local.cacheCategory = arguments.categories[local.i]; + if (StructKeyExists(application.wheels.cache, local.cacheCategory) && IsStruct(application.wheels.cache[local.cacheCategory])) { + StructClear(application.wheels.cache[local.cacheCategory]); } } } diff --git a/vendor/wheels/tests/specs/caching/CacheHardenerSpec.cfc b/vendor/wheels/tests/specs/caching/CacheHardenerSpec.cfc index 986da10ba6..765970b1c4 100644 --- a/vendor/wheels/tests/specs/caching/CacheHardenerSpec.cfc +++ b/vendor/wheels/tests/specs/caching/CacheHardenerSpec.cfc @@ -1,5 +1,5 @@ /** - * Hardener proofs for Cache BLOCKERs B1–B2 and SHOULDs S1–S8. + * Hardener proofs for Cache BLOCKERs B1–B2 and SHOULDs S1–S9. * Desk IDs are stable. Do not renumber. * * Directory-scoped so `wheels test --core --ci --filter=caching` @@ -11,27 +11,15 @@ component extends="wheels.WheelsTest" { g = application.wo - describe("CoS lock: cache public defaults stay conservative", function() { - - it("keeps cacheActions/Pages/Partials/Images/Queries off in development only", function() { - var src = FileRead(ExpandPath("/wheels/events/init/caching.cfm")); - expect(FindNoCase("application.$wheels.cacheActions = false", src)).toBeGT(0); - expect(FindNoCase("application.$wheels.cacheImages = false", src)).toBeGT(0); - expect(FindNoCase("application.$wheels.cachePages = false", src)).toBeGT(0); - expect(FindNoCase("application.$wheels.cachePartials = false", src)).toBeGT(0); - expect(FindNoCase("application.$wheels.cacheQueries = false", src)).toBeGT(0); - expect(FindNoCase("if (application.$wheels.environment != ""development"")", src)).toBeGT(0); - }); + describe("CoS lock: cacheFileChecking and named-action time/static", function() { it("keeps cacheFileChecking true", function() { var src = FileRead(ExpandPath("/wheels/events/init/caching.cfm")); expect(FindNoCase("application.$wheels.cacheFileChecking = true", src)).toBeGT(0); }); - it("keeps caches() empty-action wildcard and time/static defaults", function() { - var cachesSrc = FileRead(ExpandPath("/wheels/controller/caching.cfc")); + it("keeps caches() time=60 and static=false defaults when an action is named", function() { var fnSrc = FileRead(ExpandPath("/wheels/events/init/functions.cfm")); - expect(FindNoCase("arguments.action = ""*""", cachesSrc)).toBeGT(0); expect(FindNoCase("functions.caches = {time = 60, static = false}", fnSrc)).toBeGT(0); }); @@ -61,11 +49,12 @@ component extends="wheels.WheelsTest" { var className = first.$getControllerClassData().name; var params = {controller = "hardenerLifecycle", action = "cachedShow"}; - var expectedKey = g.$hashedKey(className, params); - expect(StructKeyExists(application.wheels.cache.action, expectedKey)).toBeTrue( - "processAction must write the action body under $hashedKey(class, params)" + var hashedKey = g.$hashedKey(className, params); + var storeKey = g.$actionCacheKey(hashedKey); + expect(StructKeyExists(application.wheels.cache.action, storeKey)).toBeTrue( + "processAction must write the action body under the session-qualified store key" ); - expect(g.$getFromCache(key = expectedKey, category = "action")).toBe("b1-write"); + expect(g.$getFromCache(key = hashedKey, category = "action")).toBe("b1-write"); expect(g.$cacheCount("action")).toBe(1); request.hardenerCachePayload = "b1-should-not-run"; @@ -73,7 +62,7 @@ component extends="wheels.WheelsTest" { expect(second.processAction()).toBeTrue(); expect(second.response()).toBe("b1-write"); expect(g.$cacheCount("action")).toBe(1); - expect(StructKeyExists(application.wheels.cache.action, expectedKey)).toBeTrue(); + expect(StructKeyExists(application.wheels.cache.action, storeKey)).toBeTrue(); }); }); @@ -136,10 +125,12 @@ component extends="wheels.WheelsTest" { "session": session, "variables": {} }; - var expectedKey = first.$appendToCacheKey( - key = g.$hashedKey(className, params), - appendToKey = "request.cacheProbeA", - scopeMap = scopeMap + var expectedKey = g.$actionCacheKey( + first.$appendToCacheKey( + key = g.$hashedKey(className, params), + appendToKey = "request.cacheProbeA", + scopeMap = scopeMap + ) ); expect(StructKeyExists(application.wheels.cache.action, expectedKey)).toBeTrue( "processAction must keep the first-match appendToKey on the cache key" @@ -159,6 +150,83 @@ component extends="wheels.WheelsTest" { }); + describe("S1 caches() requires named actions", function() { + + beforeEach(function() { + _controller = g.controller("dummy", {controller = "dummy", action = "dummy"}); + _controller.$clearCachableActions(); + }); + + it("S1: caches() with no action throws instead of silently becoming *", function() { + expect(function() { + _controller.caches(); + }).toThrow("Wheels.InvalidArgument"); + }); + + it("S1: caches(static=true) with no action throws", function() { + expect(function() { + _controller.caches(static = true); + }).toThrow("Wheels.InvalidArgument"); + }); + + it("S1: an explicit * is still allowed when named", function() { + _controller.caches(action = "*"); + expect(_controller.$cachableActions()[1].action).toBe("*"); + }); + + }); + + describe("S2 testing stays cache-off like development", function() { + + it("S2: only non-dev/non-test environments enable cacheActions/Pages/Partials/Images/Queries", function() { + var src = FileRead(ExpandPath("/wheels/events/init/caching.cfm")); + expect(FindNoCase("ListFindNoCase(""development,testing"", application.$wheels.environment)", src)).toBeGT(0); + expect(FindNoCase("if (application.$wheels.environment != ""development"")", src)).toBe(0); + }); + + }); + + describe("S3 action cache key includes session/user", function() { + + beforeEach(function() { + $beginActionCacheProbe(); + if (StructKeyExists(session, "user")) { + _priorSessionUser = Duplicate(session.user); + } + _controller = g.controller("hardenerLifecycle", {controller = "hardenerLifecycle", action = "cachedShow"}); + _controller.$clearCachableActions(); + _controller.flashClear(); + _controller.caches(action = "cachedShow"); + }); + + afterEach(function() { + _controller.$clearCachableActions(); + if (StructKeyExists(variables, "_priorSessionUser")) { + session.user = _priorSessionUser; + StructDelete(variables, "_priorSessionUser"); + } else { + StructDelete(session, "user"); + } + $endActionCacheProbe(); + }); + + it("S3: params-only pages do not leak one session's body to another", function() { + session.user = {id = "alice"}; + request.hardenerCachePayload = "payload-alice"; + var alice = g.controller("hardenerLifecycle", {controller = "hardenerLifecycle", action = "cachedShow"}); + alice.processAction(); + expect(alice.response()).toBe("payload-alice"); + + session.user = {id = "bob"}; + request.hardenerCachePayload = "payload-bob"; + var bob = g.controller("hardenerLifecycle", {controller = "hardenerLifecycle", action = "cachedShow"}); + bob.processAction(); + expect(bob.response()).toBe("payload-bob"); + expect(g.$cacheCount("action")).toBe(2); + }); + + }); + describe("S4 silent-drop when the cache is full", function() { beforeEach(function() { @@ -224,27 +292,122 @@ component extends="wheels.WheelsTest" { g.$addToCache(key = probeKey, value = "s5-body", time = 60, category = "main"); expect(g.$getFromCache(key = probeKey, category = "main")).toBe("s5-body"); g.$clearCache("main"); - expect(g.$getFromCache(key = probeKey, category = "main")).toBeFalse(); + expect(g.$isCacheMiss(g.$getFromCache(key = probeKey, category = "main"))).toBeTrue(); }); }); - describe("HELD: S7 $clearCache still wipes the bucket with StructClear", function() { + describe("S6 clearCachableActions drops this-controller action bodies", function() { - it("S7: $clearCache keeps StructClear (do not change wipe policy)", function() { - var src = FileRead(ExpandPath("/wheels/global/cache.cfm")); - expect(FindNoCase("StructClear(application.wheels.cache[arguments.category])", src)).toBeGT(0); - expect(FindNoCase("StructClear(application.wheels.cache)", src)).toBeGT(0); + beforeEach(function() { + $beginActionCacheProbe(); + _controller = g.controller("hardenerLifecycle", {controller = "hardenerLifecycle", action = "cachedShow"}); + _controller.$clearCachableActions(); + _controller.flashClear(); + }); + + afterEach(function() { + _controller.$clearCachableActions(); + $endActionCacheProbe(); + }); + + it("S6: clearCachableActions removes this controller's bodies and leaves others", function() { + _controller.caches(action = "cachedShow"); + request.hardenerCachePayload = "s6-body"; + var first = g.controller("hardenerLifecycle", {controller = "hardenerLifecycle", action = "cachedShow"}); + first.processAction(); + expect(g.$cacheCount("action")).toBeGT(0); + + application.wheels.cache.action["other-controller-decoy"] = { + expiresAt = DateAdd("n", 30, Now()), + value = "keep-other" + }; + + _controller.clearCachableActions(); + expect(StructKeyExists(application.wheels.cache.action, "other-controller-decoy")).toBeTrue(); + expect(application.wheels.cache.action["other-controller-decoy"].value).toBe("keep-other"); + expect(g.$cacheCount("action")).toBe(1); }); }); - describe("HELD: S3 action key does not fold in session by default", function() { + describe("S7 $clearCache is targeted", function() { - it("S3: $hashedKey still seeds only arguments plus cgi.http_host", function() { + beforeEach(function() { + _originalCache = Duplicate(application.wheels.cache); + }); + + afterEach(function() { + application.wheels.cache = _originalCache; + }); + + it("S7: no-arg $clearCache() clears each category and keeps the buckets", function() { + application.wheels.cache.main["s7-main"] = {expiresAt = DateAdd("n", 30, Now()), value = "m"}; + application.wheels.cache.action["s7-action"] = {expiresAt = DateAdd("n", 30, Now()), value = "a"}; + g.$clearCache(); + expect(application.wheels.cache).toHaveKey("main"); + expect(application.wheels.cache).toHaveKey("action"); + expect(IsStruct(application.wheels.cache.main)).toBeTrue(); + expect(IsStruct(application.wheels.cache.action)).toBeTrue(); + expect(StructCount(application.wheels.cache.main)).toBe(0); + expect(StructCount(application.wheels.cache.action)).toBe(0); + }); + + it("S7: $clearCache no longer wipes the parent cache struct", function() { var src = FileRead(ExpandPath("/wheels/global/cache.cfm")); - expect(FindNoCase("StructInsert(arguments, ListLen(StructKeyList(arguments)) + 1, cgi.http_host, true)", src)).toBeGT(0); - expect(FindNoCase("session", src)).toBe(0); + expect(ReFindNoCase("StructClear\s*\(\s*application\.wheels\.cache\s*\)", src)).toBe(0); + }); + + }); + + describe("S8 caches(Foo) matches action foo", function() { + + beforeEach(function() { + $beginActionCacheProbe(); + _controller = g.controller("hardenerLifecycle", {controller = "hardenerLifecycle", action = "cachedShow"}); + _controller.$clearCachableActions(); + _controller.flashClear(); + }); + + afterEach(function() { + _controller.$clearCachableActions(); + $endActionCacheProbe(); + }); + + it("S8: $cacheSettingsForAction is case-insensitive", function() { + _controller.caches(action = "CachedShow", time = 15); + var settings = _controller.$cacheSettingsForAction("cachedShow"); + expect(IsStruct(settings)).toBeTrue(); + expect(settings.time).toBe(15); + }); + + it("S8: processAction caches CachedShow when the action is cachedShow", function() { + _controller.caches(action = "CachedShow"); + request.hardenerCachePayload = "s8-body"; + var first = g.controller("hardenerLifecycle", {controller = "hardenerLifecycle", action = "cachedShow"}); + first.processAction(); + expect(first.response()).toBe("s8-body"); + expect(g.$cacheCount("action")).toBe(1); + }); + + }); + + describe("S9 stored false is not a miss", function() { + + afterEach(function() { + g.$clearCache("main"); + }); + + it("S9: a cached boolean false is distinguishable from a miss", function() { + g.$clearCache("main"); + expect(g.$isCacheMiss(g.$getFromCache(key = "s9-missing", category = "main"))).toBeTrue(); + + g.$addToCache(key = "s9-false", value = false, time = 60, category = "main"); + var hit = g.$getFromCache(key = "s9-false", category = "main"); + expect(g.$isCacheMiss(hit)).toBeFalse(); + expect(IsStruct(hit)).toBeTrue(); + expect(hit.$wheelsCacheHit).toBeTrue(); + expect(hit.value).toBeFalse(); }); }); diff --git a/vendor/wheels/tests/specs/controller/cachingSpec.cfc b/vendor/wheels/tests/specs/controller/cachingSpec.cfc index ca7fd216a7..519492ae29 100644 --- a/vendor/wheels/tests/specs/controller/cachingSpec.cfc +++ b/vendor/wheels/tests/specs/controller/cachingSpec.cfc @@ -119,14 +119,15 @@ component extends="wheels.WheelsTest" { g.$clearCache("action") _controller.flashClear() _controller.caches(action = "test") - var expectedKey = g.$hashedKey(_controller.$getControllerClassData().name, params) + var hashedKey = g.$hashedKey(_controller.$getControllerClassData().name, params) + var storeKey = g.$actionCacheKey(hashedKey) result = _controller.processAction() expect(result).toBeTrue() - expect(StructKeyExists(application.wheels.cache.action, expectedKey)).toBeTrue() - expect(Len(g.$getFromCache(key = expectedKey, category = "action"))).toBeGT(0) + expect(StructKeyExists(application.wheels.cache.action, storeKey)).toBeTrue() + expect(Len(g.$getFromCache(key = hashedKey, category = "action"))).toBeGT(0) - application.wheels.cache.action[expectedKey].value = "b1-cache-hit-probe" + application.wheels.cache.action[storeKey].value = "b1-cache-hit-probe" var second = g.controller("test", params) second.processAction() expect(second.response()).toBe("b1-cache-hit-probe") @@ -159,12 +160,18 @@ component extends="wheels.WheelsTest" { expect(r[2].static).toBeTrue() }) - it("is specifying actions to cache with options", () => { - _controller.caches(static = true) + it("is specifying a named action to cache as static", () => { + _controller.caches(action = "dummy", static = true) r = _controller.$cacheSettingsForAction("dummy") expect(r.static).toBeTrue() }) + + it("throws when caches() is called with no action", () => { + expect(() => { + _controller.caches(static = true) + }).toThrow("Wheels.InvalidArgument") + }) }) describe("Tests for clearCachableActions(action)", () => { From 9145bd20765faba3fe8d76852670438ea2b1352b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 01:14:09 +0000 Subject: [PATCH 4/5] fix(cache): return stored false as a cache hit (S9) $getFromCache must give back a stored falsey payload instead of collapsing it into a miss. A miss is only an absent, expired, or culled key. $isCacheMiss() now reads the last lookup, not the value. Signed-off-by: Cursor Agent Co-authored-by: Peter Amiri --- changelog.d/cache-hardener-b1-b2.fixed.md | 2 +- vendor/wheels/global/cache.cfm | 16 +++++++---- .../tests/specs/caching/CacheHardenerSpec.cfc | 28 ++++++++++++++----- 3 files changed, 32 insertions(+), 14 deletions(-) diff --git a/changelog.d/cache-hardener-b1-b2.fixed.md b/changelog.d/cache-hardener-b1-b2.fixed.md index bbe21d989c..447ccf9133 100644 --- a/changelog.d/cache-hardener-b1-b2.fixed.md +++ b/changelog.d/cache-hardener-b1-b2.fixed.md @@ -5,5 +5,5 @@ - `clearCachableActions` drops this controller's action bodies, not metadata only - `$clearCache()` clears each category in place and no longer `StructClear`s the parent bucket - `caches("Foo")` matches action `foo` -- `$getFromCache` boxes a stored `false` so it is not a miss +- `$getFromCache` returns a stored `false` (or other falsey payload) as a hit. A miss is only absent, expired, or culled. `$isCacheMiss()` reads the last lookup, not the value - `$addToCache` / `$getFromCache` / `$clearCache` take a named exclusive `wheelsCacheStore` lock diff --git a/vendor/wheels/global/cache.cfm b/vendor/wheels/global/cache.cfm index d861ead085..3adca779c3 100644 --- a/vendor/wheels/global/cache.cfm +++ b/vendor/wheels/global/cache.cfm @@ -72,10 +72,11 @@ } /** - * True when $getFromCache returned a miss (boolean false), not a stored false. + * True when the last $getFromCache was a miss (absent, expired, or culled). + * A stored falsey value is a hit. Do not infer miss from the returned value. */ - public boolean function $isCacheMiss(required any result) { - return IsBoolean(arguments.result) && !arguments.result; + public boolean function $isCacheMiss() { + return !IsDefined("request.wheels.cacheLastHit") || !request.wheels.cacheLastHit; } @@ -207,6 +208,7 @@ */ public any function $getFromCache(required string key, string category = "main") { local.rv = false; + local.hit = false; lock name="#application.applicationName#wheelsCacheStore" type="exclusive" timeout="30" { try { local.storeKey = arguments.key; @@ -222,14 +224,16 @@ } else { local.rv = Duplicate(application.wheels.cache[arguments.category][local.storeKey].value); } - if (IsBoolean(local.rv) && !local.rv) { - local.rv = {$wheelsCacheHit = true, value = false}; - } + local.hit = true; } } } catch (any e) { } } + if (!StructKeyExists(request, "wheels")) { + request.wheels = {}; + } + request.wheels.cacheLastHit = local.hit; return local.rv; } diff --git a/vendor/wheels/tests/specs/caching/CacheHardenerSpec.cfc b/vendor/wheels/tests/specs/caching/CacheHardenerSpec.cfc index 765970b1c4..c4b55dee62 100644 --- a/vendor/wheels/tests/specs/caching/CacheHardenerSpec.cfc +++ b/vendor/wheels/tests/specs/caching/CacheHardenerSpec.cfc @@ -292,7 +292,8 @@ component extends="wheels.WheelsTest" { g.$addToCache(key = probeKey, value = "s5-body", time = 60, category = "main"); expect(g.$getFromCache(key = probeKey, category = "main")).toBe("s5-body"); g.$clearCache("main"); - expect(g.$isCacheMiss(g.$getFromCache(key = probeKey, category = "main"))).toBeTrue(); + g.$getFromCache(key = probeKey, category = "main"); + expect(g.$isCacheMiss()).toBeTrue(); }); }); @@ -398,16 +399,29 @@ component extends="wheels.WheelsTest" { g.$clearCache("main"); }); - it("S9: a cached boolean false is distinguishable from a miss", function() { + it("S9: $addToCache(key, false) then $getFromCache returns false as a hit", function() { g.$clearCache("main"); - expect(g.$isCacheMiss(g.$getFromCache(key = "s9-missing", category = "main"))).toBeTrue(); + + var miss = g.$getFromCache(key = "s9-missing", category = "main"); + expect(miss).toBeFalse(); + expect(g.$isCacheMiss()).toBeTrue(); + expect(StructKeyExists(application.wheels.cache.main, "s9-missing")).toBeFalse(); g.$addToCache(key = "s9-false", value = false, time = 60, category = "main"); var hit = g.$getFromCache(key = "s9-false", category = "main"); - expect(g.$isCacheMiss(hit)).toBeFalse(); - expect(IsStruct(hit)).toBeTrue(); - expect(hit.$wheelsCacheHit).toBeTrue(); - expect(hit.value).toBeFalse(); + expect(hit).toBeFalse(); + expect(g.$isCacheMiss()).toBeFalse(); + expect(StructKeyExists(application.wheels.cache.main, "s9-false")).toBeTrue(); + }); + + it("S9: other falsey stored payloads are hits, not misses", function() { + g.$clearCache("main"); + g.$addToCache(key = "s9-zero", value = 0, time = 60, category = "main"); + g.$addToCache(key = "s9-blank", value = "", time = 60, category = "main"); + expect(g.$getFromCache(key = "s9-zero", category = "main")).toBe(0); + expect(g.$isCacheMiss()).toBeFalse(); + expect(g.$getFromCache(key = "s9-blank", category = "main")).toBe(""); + expect(g.$isCacheMiss()).toBeFalse(); }); }); From b9e209137815fd5a787ffdea6697b633ef35537c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 01:20:04 +0000 Subject: [PATCH 5/5] test(cache): clear leftover dummy1 before settings lookup B2 first-match returns the leftover default time=60 row that earlier dummy specs leave on the controller class. Clear before caches() so the time=100 row is the first match. Signed-off-by: Cursor Agent Co-authored-by: Peter Amiri --- vendor/wheels/tests/specs/controller/cachingSpec.cfc | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/vendor/wheels/tests/specs/controller/cachingSpec.cfc b/vendor/wheels/tests/specs/controller/cachingSpec.cfc index 519492ae29..ebcf205697 100644 --- a/vendor/wheels/tests/specs/controller/cachingSpec.cfc +++ b/vendor/wheels/tests/specs/controller/cachingSpec.cfc @@ -37,7 +37,18 @@ component extends="wheels.WheelsTest" { it("is getting cache settings for action", () => { _controller = application.wo.controller(name = "dummy") + _controller.$clearCachableActions() + _controller.caches(action = "dummy1", time = 100) + r = _controller.$cacheSettingsForAction("dummy1") + + expect(r.time).toBe(100) + }) + + it("keeps first-match when two caches() rows name the same action", () => { + _controller = application.wo.controller(name = "dummy") + _controller.$clearCachableActions() _controller.caches(action = "dummy1", time = 100) + _controller.caches(action = "dummy1", time = 5) r = _controller.$cacheSettingsForAction("dummy1") expect(r.time).toBe(100)