Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ All historical references to "CFWheels" in this changelog have been preserved fo

### Fixed

- `paginationNav()` now throws `Wheels.PaginationNav.InvalidArgument` when passed an argument that none of its sub-helpers (`paginationInfo`, `firstPageLink`, `previousPageLink`, `pageNumberLinks`, `nextPageLink`, `lastPageLink`) accept. Previously, typos such as `prependToList="<ul>"` were silently dropped by CFML's `argumentCollection` dispatch, leaving users to wonder why a styling argument had no effect. The check is gated on `application.wheels.showErrorInformation` so production is unaffected; development environments fail fast and the error names both the rejected arguments and the full allowlist of accepted pass-through keys (#2717)
- `wheels --help` no longer summarises the `packages` command as `Install, update, search Wheels packages` — that phrasing nudged users to type `wheels packages install <name>`, which LuCLI's built-in extension installer intercepts before module dispatch and silently no-ops (`[INFO] No git or extension dependencies to install`, exit 0, nothing under `vendor/`). The summary now leads with the canonical verb (`Add, update, search ...`) and parenthesises the gotcha so the doc surface stops contradicting the runtime. Same trap that earlier renamed `wheels browser install` to `wheels browser setup` (#2706)
- `wheels` `.deb` / `.rpm` Linux packages now ship the lucli-native `wheels-module` artifact, version + channel stamps, and a wrapper that routes through the bundled module — fixing the three v4.0.0 rpm regressions that broke `wheels start` on Rocky Linux during the titan production cutover. (1) `build-linux-packages.sh` now untars `wheels-module-${WHEELS_VERSION}.tar.gz` into `/opt/wheels/module/` instead of unzipping the CommandBox-shaped `wheels-cli-${WHEELS_VERSION}.zip`. (2) The LuCLI binary is staged as `/opt/wheels/wheels` so `basename(argv[0])` is `wheels` when the wrapper execs it — mirroring the brew formula and making LuCLI's module dispatcher resolve `wheels start` against the bundled module. (3) `nfpm-wheels.yaml` and `nfpm-wheels-be.yaml` now declare `/opt/wheels/.version` and `/opt/wheels/.channel` under `contents:` so `wheels --version` no longer returns `unknown (stable)`. (4) `tar` is declared as an rpm + deb runtime dependency since Rocky Linux 10 minimal cloud images do not ship it and any role that unpacks a tarball payload fails silently without it (#2700)
- `wheels.middleware.RateLimiter` now validates `windowSeconds > 0` and `maxRequests >= 0` at construction. Previously, `windowSeconds = 0` leaked a generic CFML `You cannot divide by zero` exception out of the `fixedWindow` and `tokenBucket` strategies (and let every request through on `slidingWindow`), with no pointer back to the misconfigured `set(middleware = [...])` line. The constructor now throws `Wheels.RateLimiter.InvalidConfiguration` with a message naming the bad parameter — matching the pattern already used for `strategy`, `storage`, and `proxyStrategy`. `maxRequests = 0` remains legal (kill-switch idiom for "block every request") (#2693)
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,8 @@ Requires a paginated query: `findAll(page=params.page, perPage=25)`. The recomme

Disabled links render as `<span class="disabled">` by default. All helpers accept `handle` for named pagination queries.

In development (`showErrorInformation = true`), `paginationNav()` throws `Wheels.PaginationNav.InvalidArgument` if passed an argument not accepted by any of its sub-helpers. Accepted pass-through keys: `format`, `text`, `name`, `class`, `disabledClass`, `showDisabled`, `pageNumberAsParam`, `windowSize`, `classForCurrent`, `linkToCurrentPage`, `prependToPage`, `appendToPage`, `route`, `controller`, `action`, `key`, `anchor`, `onlyPath`, `host`, `protocol`, `port`, `params`. Named route segment variables (e.g., `userId` when `route="userTimeline"` and the pattern contains `[userId]`) are automatically exempted from this check. In production the unknown argument is silently dropped.

## Testing Quick Reference

**All new tests use WheelsTest BDD syntax.** RocketUnit (`test_` prefix, `assert()`) is legacy only — never use it for new tests.
Expand Down
74 changes: 74 additions & 0 deletions vendor/wheels/tests/specs/view/paginationHelpersSpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -280,10 +280,84 @@ component extends="wheels.WheelsTest" {
expect(result).toInclude("custom-pagination")
})

it("throws when passed an unknown argument and showErrorInformation is on", () => {
_origShowErr = application.wheels.showErrorInformation
application.wheels.showErrorInformation = true
try {
g.model("author").findAll(page = 2, perPage = 3, order = "lastName")
expect(() => _controller.paginationNav(prependToList = "<ul>"))
.toThrow(type = "Wheels.PaginationNav.InvalidArgument")
} finally {
application.wheels.showErrorInformation = _origShowErr
}
})

it("does not throw for documented sub-helper args", () => {
_origShowErr = application.wheels.showErrorInformation
application.wheels.showErrorInformation = true
try {
g.model("author").findAll(page = 2, perPage = 3, order = "lastName")
expect(() => _controller.paginationNav(
windowSize = 3,
classForCurrent = "active",
prependToPage = "<li>",
appendToPage = "</li>",
class = "page-link"
)).notToThrow()
} finally {
application.wheels.showErrorInformation = _origShowErr
}
})

it("does not throw on unknown arg when showErrorInformation is off", () => {
_origShowErr = application.wheels.showErrorInformation
application.wheels.showErrorInformation = false
try {
g.model("author").findAll(page = 2, perPage = 3, order = "lastName")
expect(() => _controller.paginationNav(prependToList = "<ul>"))
.notToThrow()
} finally {
application.wheels.showErrorInformation = _origShowErr
}
})

it("does not throw when passing a named-route segment variable", () => {
// Regression test for the C1 false positive: paginationNav(route=..., <segmentVar>=...)
// must not trip InvalidArgument, because $paginationLinkToArgs forwards the route's
// foundvariables to linkTo() at link-build time.
_origShowErr = application.wheels.showErrorInformation
_origRoutes = Duplicate(application.wheels.routes)
_origStaticRoutes = StructKeyExists(application.wheels, "staticRoutes") ? StructCopy(application.wheels.staticRoutes) : {}
_origNamedRoutePositions = StructKeyExists(application.wheels, "namedRoutePositions") ? StructCopy(application.wheels.namedRoutePositions) : {}
_origRewrite = application.wheels.URLRewriting
application.wheels.showErrorInformation = true
try {
$clearRoutes()
g.mapper().$match(name = "userTimeline", pattern = "users/[userId]/timeline", to = "users##timeline").end()
g.$setNamedRoutePositions()
application.wheels.URLRewriting = "On"
g.model("author").findAll(page = 2, perPage = 3, order = "lastName")
expect(() => _controller.paginationNav(route = "userTimeline", userId = 42))
.notToThrow()
} finally {
application.wheels.showErrorInformation = _origShowErr
application.wheels.routes = _origRoutes
application.wheels.staticRoutes = _origStaticRoutes
application.wheels.namedRoutePositions = _origNamedRoutePositions
application.wheels.URLRewriting = _origRewrite
}
})

})

})

}

public void function $clearRoutes() {
application.wheels.routes = []
application.wheels.staticRoutes = {}
application.wheels.namedRoutePositions = {}
}

}
52 changes: 46 additions & 6 deletions vendor/wheels/view/pagination.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -336,23 +336,63 @@ component {
any encode
) {
$args(name = "paginationNav", args = arguments);
local.pg = pagination(arguments.handle);

// Return empty if only one page and showSinglePage is false
if (local.pg.totalPages <= 1 && !arguments.showSinglePage) {
return "";
}

// Build passthrough arguments for sub-helpers
local.subArgs = {};
local.subArgs.handle = arguments.handle;
local.subArgs.encode = arguments.encode;
// Pass through any extra arguments (route, controller, action, key, params, etc.)
local.skipArgs = "handle,navClass,showFirst,showLast,showPrevious,showNext,showInfo,showSinglePage,encode";
// Union of args accepted by sub-helpers (paginationInfo, firstPageLink,
// previousPageLink, pageNumberLinks, nextPageLink, lastPageLink) plus the
// URL-building keys forwarded by $paginationLinkToArgs. Keys outside this
// allowlist are silently dropped by CFML's argumentCollection dispatch,
// which makes typos like prependToList="<ul>" invisible — see issue #2717.
local.allowedSubArgs = "format,text,name,class,disabledClass,showDisabled,pageNumberAsParam"
& ",windowSize,classForCurrent,linkToCurrentPage,prependToPage,appendToPage"
& ",route,controller,action,key,anchor,onlyPath,host,protocol,port,params";
local.unknownArgs = "";
for (local.key in arguments) {
if (!ListFindNoCase(local.skipArgs, local.key)) {
local.subArgs[local.key] = arguments[local.key];
if (!ListFindNoCase(local.allowedSubArgs, local.key)) {
local.unknownArgs = ListAppend(local.unknownArgs, local.key);
}
}
}
// Validate before the totalPages early-return so the check fires on
// single-page (or empty) result sets too. Gated on showErrorInformation
// so production skips both the $findRoute lookup and the throw entirely.
if (Len(local.unknownArgs) && application.wheels.showErrorInformation) {
// Named-route segment variables (e.g. userId in route "userTimeline") are
// forwarded by $paginationLinkToArgs at link-build time but are not in the
// static allowlist. Filter them out before throwing — otherwise
// paginationNav(route="userTimeline", userId=user.id) trips a false-positive
// InvalidArgument.
if (StructKeyExists(local.subArgs, "route") && Len(local.subArgs.route)) {
local.routeVarList = $findRoute(argumentCollection = local.subArgs).foundvariables;
local.filteredUnknown = "";
for (local.uk in ListToArray(local.unknownArgs)) {
if (!ListFindNoCase(local.routeVarList, local.uk)) {
local.filteredUnknown = ListAppend(local.filteredUnknown, local.uk);
}
}
local.unknownArgs = local.filteredUnknown;
}
if (Len(local.unknownArgs)) {
Throw(
type = "Wheels.PaginationNav.InvalidArgument",
message = "paginationNav() received unknown argument(s): [#local.unknownArgs#].",
detail = "Accepted pass-through arguments are: #local.allowedSubArgs#. paginationNav's own arguments are: #local.skipArgs#."
);
}
}

local.pg = pagination(arguments.handle);

// Return empty if only one page and showSinglePage is false
if (local.pg.totalPages <= 1 && !arguments.showSinglePage) {
return "";
}

local.content = "";
Expand Down
Loading