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 000000000..447ccf913 --- /dev/null +++ b/changelog.d/cache-hardener-b1-b2.fixed.md @@ -0,0 +1,9 @@ +- `$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` 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/controller/caching.cfc b/vendor/wheels/controller/caching.cfc index 1f751988e..ebd104cb0 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]; @@ -75,26 +74,72 @@ 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++) { - 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; + local.rv.appendToKey = StructKeyExists(local.cachableActions[local.i], "appendToKey") + ? local.cachableActions[local.i].appendToKey + : ""; + return local.rv; } } - return local.rv; + return false; } /** * 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 11c85d9ce..393c4a881 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 3219942c7..3adca779c 100644 --- a/vendor/wheels/global/cache.cfm +++ b/vendor/wheels/global/cache.cfm @@ -45,6 +45,40 @@ 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 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() { + return !IsDefined("request.wheels.cacheLastHit") || !request.wheels.cacheLastHit; + } + /** * Internal function. @@ -96,6 +130,11 @@ numeric time = application.wheels.defaultCacheTime, 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 @@ -141,7 +180,25 @@ } 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; + } + } } } @@ -151,20 +208,32 @@ */ 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; + local.hit = false; + lock name="#application.applicationName#wheelsCacheStore" type="exclusive" timeout="30" { + try { + 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 { - local.rv = Duplicate(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][local.storeKey].value); + } + local.hit = true; } } + } catch (any e) { } - } catch (any e) { } + if (!StructKeyExists(request, "wheels")) { + request.wheels = {}; + } + request.wheels.cacheLastHit = local.hit; return local.rv; } @@ -197,10 +266,30 @@ * 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)) { + if (StructKeyExists(application.wheels.cache, arguments.category) && IsStruct(application.wheels.cache[arguments.category])) { + StructClear(application.wheels.cache[arguments.category]); + } + } else { + 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 new file mode 100644 index 000000000..c4b55dee6 --- /dev/null +++ b/vendor/wheels/tests/specs/caching/CacheHardenerSpec.cfc @@ -0,0 +1,454 @@ +/** + * 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` + * discovers this folder (a single-file directory= scope finds 0 bundles). + */ +component extends="wheels.WheelsTest" { + + function run() { + + g = application.wo + + 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() time=60 and static=false defaults when an action is named", function() { + var fnSrc = FileRead(ExpandPath("/wheels/events/init/functions.cfm")); + 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 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 = hashedKey, 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, storeKey)).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 = 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" + ); + + 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("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() { + _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"); + g.$getFromCache(key = probeKey, category = "main"); + expect(g.$isCacheMiss()).toBeTrue(); + }); + + }); + + describe("S6 clearCachableActions drops this-controller action bodies", function() { + + 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("S7 $clearCache is targeted", 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(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: $addToCache(key, false) then $getFromCache returns false as a hit", function() { + g.$clearCache("main"); + + 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(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(); + }); + + }); + + } + + 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 50ec17638..ebcf20569 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) @@ -109,10 +120,38 @@ 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 hashedKey = g.$hashedKey(_controller.$getControllerClassData().name, params) + var storeKey = g.$actionCacheKey(hashedKey) + result = _controller.processAction() + + expect(result).toBeTrue() + expect(StructKeyExists(application.wheels.cache.action, storeKey)).toBeTrue() + expect(Len(g.$getFromCache(key = hashedKey, category = "action"))).toBeGT(0) + + 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") + } 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", () => { @@ -132,12 +171,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)", () => {