diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ecf465a16..4066a60295 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ All historical references to "CFWheels" in this changelog have been preserved fo ### Fixed +- Routes registered inside `.namespace("foo")` (or equivalent `.scope()` / `.package()`) with a redundant namespace prefix in the controller path — e.g. `to="foo/dashboard##index"` instead of `to="dashboard##index"` — previously silently produced a `foo.foo/dashboard` lookup that downstream flattened to a `Foodashboard`-style class name with an opaque `Wheels.ViewNotFound` error. The Mapper now rejects this at route-registration time with `Wheels.MapperArgumentInvalid`, naming the namespace and the offending value and pointing at the correct shorter form, so users can find the bad route definition instead of chasing the symptom (#2791) - `WheelsTest` auto-bind missed user-defined global helpers added via `include` in `app/global/functions.cfm`. The pseudo-constructor used `getMetaData(application.wo).functions`, which only enumerates methods declared directly on the CFC and skips symbols merged in via `cfinclude`. Specs that called custom helpers (e.g. `can()`, `hasRole()`) had to manually rebind each one in `beforeAll()`. The auto-bind now iterates `application.wo` as a struct and binds every UDF via `isCustomFunction()`, preserving the existing public-only filter for declared methods (#2790) - Model layer SELECT clause builder now routes column identifiers through the adapter's `$quoteIdentifier`, so reserved-word column names (e.g. `key`, `order`, `group`) survive on every supported dialect instead of breaking `findAll` / `findOne` / dynamic finders with cryptic SQL syntax errors. The WHERE / ORDER BY paths already quoted columns; `$createSQLFieldList` and the empty-pagination column-list extraction in `read.cfc` now match. - `wheels packages install ` is now a transparent alias for `wheels packages add ` on every caller path that actually reaches `Module.cfc` (stdio MCP server, scripted in-process clients, spec suite). Previously the dispatch layer's `case "install":` branch printed a yellow warning to stdout and returned `""` without installing anything — so even though `PackagesMainCli.install()` itself had been a true alias for `add()` since #2729, any caller routing through the CLI module's verb dispatch silently no-op'd. The shell-facing `wheels packages install ` form is still intercepted by LuCLI's built-in extension installer upstream of module dispatch and remains broken on that path (and is documented as such in the module-owned help text), but MCP tool calls and programmatic callers now behave identically to `add`. Both branches now share a single fall-through body so the validation, error shape, and install behavior cannot drift apart again. diff --git a/vendor/wheels/mapper/matching.cfc b/vendor/wheels/mapper/matching.cfc index cd6e184695..5032c329b4 100644 --- a/vendor/wheels/mapper/matching.cfc +++ b/vendor/wheels/mapper/matching.cfc @@ -303,12 +303,42 @@ component { } // Interpret "to" as "controller##action". + local.fromTo = false; + local.originalTo = ""; if (StructKeyExists(arguments, "to")) { + local.fromTo = true; + local.originalTo = arguments.to; arguments.controller = ListFirst(arguments.to, "##"); arguments.action = ListLast(arguments.to, "##"); StructDelete(arguments, "to"); } + // Guard: reject redundant namespace prefix in to=/controller= (#2791). + if ( + StructKeyExists(arguments, "package") + && Len(arguments.package) > 0 + && StructKeyExists(arguments, "controller") + && Find("/", arguments.controller) + ) { + local.packageAsPath = Replace(arguments.package, ".", "/", "all"); + local.prefix = local.packageAsPath & "/"; + if (Len(arguments.controller) > Len(local.prefix) && Left(arguments.controller, Len(local.prefix)) == local.prefix) { + local.stripped = Mid(arguments.controller, Len(local.prefix) + 1, Len(arguments.controller) - Len(local.prefix)); + local.actionForMsg = StructKeyExists(arguments, "action") ? arguments.action : "action"; + local.hh = "####"; + if (local.fromTo) { + local.detail = "Got controller=""" & arguments.controller & """ (from to=""" & local.originalTo & """). The namespace prefix is added automatically — use to=""" & local.stripped & local.hh & local.actionForMsg & """ instead."; + } else { + local.detail = "Got controller=""" & arguments.controller & """ (passed as controller=). The namespace prefix is added automatically — use controller=""" & local.stripped & """ (or to=""" & local.stripped & local.hh & local.actionForMsg & """) instead."; + } + Throw( + type = "Wheels.MapperArgumentInvalid", + message = "Route inside `.namespace('#arguments.package#')` (or equivalent `.scope()` / `.package()`) uses a redundant namespace prefix in its controller path.", + detail = local.detail + ); + } + } + // Pull route name from arguments if it exists. local.name = ""; if (StructKeyExists(arguments, "name")) { diff --git a/vendor/wheels/tests/specs/mapper/MatchingSpec.cfc b/vendor/wheels/tests/specs/mapper/MatchingSpec.cfc index 70568e7e8e..f900b81abb 100644 --- a/vendor/wheels/tests/specs/mapper/MatchingSpec.cfc +++ b/vendor/wheels/tests/specs/mapper/MatchingSpec.cfc @@ -197,6 +197,77 @@ component extends="wheels.WheelsTest" { expect(r[4]).toHaveKey("redirect") expect(r[5]).toHaveKey("redirect") }); + + // Guard against redundant namespace prefix in to=/controller= (#2791). + it("Rejects to= with redundant namespace prefix", function(){ + expect(function() { + m.$draw() + .namespace("datapai") + .get(name = "datapaiDashboard", pattern = "dashboard", to = "datapai/dashboard##index") + .end() + .end(); + }).toThrow(type = "Wheels.MapperArgumentInvalid"); + }); + it("Rejects controller= with redundant namespace prefix", function(){ + expect(function() { + m.$draw() + .namespace("admin") + .$match(name = "dashboard", method = "get", controller = "admin/dashboard", action = "index") + .end() + .end(); + }).toThrow(type = "Wheels.MapperArgumentInvalid"); + }); + it("Rejects redundant prefix in nested namespaces", function(){ + expect(function() { + m.$draw() + .namespace("admin") + .namespace("users") + .get(name = "edit", pattern = "edit", to = "admin/users/profile##edit") + .end() + .end() + .end(); + }).toThrow(type = "Wheels.MapperArgumentInvalid"); + }); + it("Rejects redundant prefix inside .package() too", function(){ + expect(function() { + m.$draw() + .package("admin") + .get(name = "users", pattern = "users", to = "admin/users##index") + .end() + .end(); + }).toThrow(type = "Wheels.MapperArgumentInvalid"); + }); + it("Allows controllers whose name only happens to share a prefix with the namespace", function(){ + // "foobar/dashboard" inside .namespace("foo") is not a redundant prefix — "foobar" != "foo". + m.$draw() + .namespace("foo") + .get(name = "dashboard", pattern = "dashboard", to = "foobar/dashboard##index") + .end() + .end(); + r = m.getRoutes(); + expect(r).toBeArray(); + expect(ArrayLen(r) >= 1).toBeTrue(); + }); + it("Accepts the correct (non-redundant) form inside a namespace", function(){ + m.$draw() + .namespace("datapai") + .get(name = "datapaiDashboard", pattern = "dashboard", to = "dashboard##index") + .end() + .end(); + r = m.getRoutes(); + expect(r).toBeArray(); + expect(ArrayLen(r) >= 1).toBeTrue(); + expect(r[1].controller).toBe("datapai.dashboard"); + expect(r[1].action).toBe("index"); + }); + it("Does not falsely reject when package is empty", function(){ + m.$draw() + .$match(name = "edge", method = "get", pattern = "edge", controller = "/users", action = "index", package = "") + .end(); + r = m.getRoutes(); + expect(r).toBeArray(); + expect(ArrayLen(r) >= 1).toBeTrue(); + }); }); }