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 @@ -33,6 +33,7 @@ All historical references to "CFWheels" in this changelog have been preserved fo
- Interpolate plugin and package names in the "Loading plugin..." / "Loading package..." `wheels_security.log` INFO lines so operators can read which plugin/package was being loaded; the call sites were double-escaping the pound signs (`##var##`) and emitting literal `#var#` placeholders instead of resolved values (#2630)
- Update the scaffolded `config/routes.cfm` doc-URL comment in `cli/src/templates/ConfigRoutes.txt` and `cli/lucli/templates/app/app/snippets/ConfigRoutes.txt` from the dead `https://guides.wheels.dev/docs/routing` path to the canonical `https://guides.wheels.dev/v4-0-0-snapshot/handling-requests-with-controllers/routing` URL, so freshly scaffolded apps no longer ship a broken link (#2635)
- `wheels new --no-sqlite` now suppresses the SQLite datasource pair in the scaffolded `lucee.json` so Lucee no longer auto-creates `db/development.sqlite` / `db/test.sqlite` on first connection (#2621)
- Extend `wheels upgrade check` for 3.x → 4.x to scan seven additional documented breakers (CORS deny-all default, RateLimiter hardened defaults, `allowEnvironmentSwitchViaUrl`, missing `csrfEncryptionKey`, legacy `wheels snippets` invocations in build/CI scripts, `tests/specs/functions/` rename, `viteStrictManifest` default flip); previously the tool only flagged the legacy plugin directory, `wheels.Test` base class, and `application.wirebox` references — silence on the rest read as a green light (#2628)

### Documentation

Expand Down
170 changes: 153 additions & 17 deletions cli/lucli/Module.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -3612,6 +3612,92 @@ component extends="modules.BaseModule" {
extensions: "cfc,cfm",
fix: "Use service() or inject() from the DI container instead"
});
// CORS default flip — wildcard "*" → deny-all (#2039). A bare
// `new wheels.middleware.Cors()` accepts no requests in 4.0.
arrayAppend(checks, {
description: "CORS middleware without allowOrigins (deny-all default in 4.0)",
pattern: "new\s+wheels\.middleware\.Cors\s*\(\s*\)",
checkType: "grep",
scanDir: "config",
extensions: "cfm,cfc",
fix: 'Pass allowOrigins explicitly: new wheels.middleware.Cors(allowOrigins="https://myapp.com")'
});
// RateLimiter hardened defaults (#2024 trustProxy=false, #2088
// proxyStrategy="last"). Advisory only: the scan flags every
// RateLimiter invocation regardless of current config, because
// multi-line argument parsing is out of scope. Users whose
// config already sets both flags should treat the hit as a
// reminder to re-verify, not a false positive.
arrayAppend(checks, {
description: "RateLimiter middleware — defaults changed in 4.0 (advisory: review config)",
pattern: "new\s+wheels\.middleware\.RateLimiter",
checkType: "grep",
scanDir: "config",
extensions: "cfm,cfc",
fix: 'Advisory check — fires on every RateLimiter usage regardless of current config. 4.0 defaults: trustProxy=false, proxyStrategy="last". If your app sits behind a proxy or load balancer, confirm both flags are set explicitly.'
});
// allowEnvironmentSwitchViaUrl defaults to false in production
// (#2076). Explicit `true` is now a security concern.
arrayAppend(checks, {
description: "allowEnvironmentSwitchViaUrl=true (default flipped to false in production)",
pattern: "allowEnvironmentSwitchViaUrl\s*=\s*true",
checkType: "grep",
scanDir: "config",
extensions: "cfm,cfc",
fix: "Re-enable only for controlled staging environments. The 4.0 default rejects ?environment=... in production."
});
// CSRF key auto-generates when empty (#2054) but cookies rotate
// on every deploy when that happens. Warn if config/ never sets
// csrfEncryptionKey.
arrayAppend(checks, {
description: "Missing csrfEncryptionKey (CSRF cookies rotate on every deploy)",
pattern: "csrfEncryptionKey",
checkType: "grep",
scanDir: "config",
extensions: "cfm,cfc",
absent: true,
fix: 'Set a stable key: set(csrfEncryptionKey = env("WHEELS_CSRF_KEY")).'
});
// `wheels snippets` → `wheels generate snippets` rename (#1852).
// Scan build / CI scripts; the CLI command is invoked from
// outside the app's own .cfm/.cfc files.
arrayAppend(checks, {
description: "Legacy 'wheels snippets' invocation (renamed to 'wheels generate snippets')",
pattern: "\bwheels\s+snippets\b",
checkType: "grep",
scanTargets: [
{path: "Makefile"},
{path: "package.json"},
{path: ".github/workflows", extensions: "yml,yaml", recurse: true},
{path: ".", extensions: "sh", recurse: false}
],
fix: "Rename to 'wheels generate snippets' in scripts, CI jobs, and IDE integrations."
});
// tests/specs/functions/ → tests/specs/functional/ rename (#1872).
// `pattern` is intentionally empty — `checkType: "directory"` signals on
// path existence and never reaches the grep loop. Do NOT replace this
// with a benign regex: `reFindNoCase("", anyString)` matches every line,
// so a future refactor that unifies the directory and grep branches
// would silently false-positive on every scanned file otherwise.
arrayAppend(checks, {
description: "Legacy tests/specs/functions/ directory (renamed to functional/)",
pattern: "",
checkType: "directory",
path: "tests/specs/functions",
fix: "Rename to tests/specs/functional/. No code changes required."
});
// Vite manifest strictness — viteStrictManifest defaults to true
// in 4.0 (#2133). Missing manifest entries now throw in
// production; flag any view that references the helpers so the
// user knows the default has flipped.
arrayAppend(checks, {
description: "Vite asset helpers (viteStrictManifest defaults to true in 4.0)",
pattern: "viteScriptTag|viteStyleTag|vitePreloadTag",
checkType: "grep",
scanDir: "app/views",
extensions: "cfm,cfc",
fix: "Missing manifest entries throw Wheels.ViteAssetNotFound in production. Rebuild assets during deploy (npm run build) or set(viteStrictManifest=false) to restore 3.x silent fallback."
});
}

// Run checks
Expand All @@ -3632,29 +3718,79 @@ component extends="modules.BaseModule" {
arrayAppend(passed, check.description);
}
} else if (check.checkType == "grep") {
var scanPath = variables.projectRoot & "/" & check.scanDir;
if (!directoryExists(scanPath)) {
arrayAppend(passed, check.description);
continue;
// Build the file set to scan. Checks may use `scanDir` +
// `extensions` (recursive scan of one directory) and/or
// `scanTargets` (mixed list of file paths and directory
// roots — needed by the `wheels snippets` rename check that
// has to look at Makefile, package.json, .github/workflows/,
// and top-level *.sh files in one shot).
var filesToScan = [];

if (structKeyExists(check, "scanDir") && len(check.scanDir)) {
var scanPath = variables.projectRoot & "/" & check.scanDir;
if (directoryExists(scanPath)) {
for (var ext in listToArray(check.extensions)) {
var dirFiles = directoryList(scanPath, true, "path", "*." & ext);
for (var f in dirFiles) arrayAppend(filesToScan, f);
}
}
}
var matches = [];
for (var ext in listToArray(check.extensions)) {
var files = directoryList(scanPath, true, "path", "*." & ext);
for (var filePath in files) {
var content = fileRead(filePath);
var lines = listToArray(content, chr(10), true);
for (var lineNum = 1; lineNum <= arrayLen(lines); lineNum++) {
if (reFindNoCase(check.pattern, lines[lineNum])) {
var relPath = replace(filePath, variables.projectRoot & "/", "");
arrayAppend(matches, "#relPath#:#lineNum#");

if (structKeyExists(check, "scanTargets") && isArray(check.scanTargets)) {
for (var target in check.scanTargets) {
var targetPath = variables.projectRoot & "/" & target.path;
if (fileExists(targetPath)) {
arrayAppend(filesToScan, targetPath);
} else if (directoryExists(targetPath)) {
var recurse = structKeyExists(target, "recurse") ? target.recurse : true;
// Avoid Elvis `?:` on `check.extensions` — Adobe CF
// throws when the key is absent. The `wheels snippets`
// check has no top-level `extensions`, so this branch
// is reached on every Adobe CF run when a target is a
// directory without its own `extensions` key.
var exts = structKeyExists(target, "extensions") ? target.extensions
: (structKeyExists(check, "extensions") ? check.extensions : "");
for (var ext in listToArray(exts)) {
var dirFiles2 = directoryList(targetPath, recurse, "path", "*." & ext);
for (var f in dirFiles2) arrayAppend(filesToScan, f);
}
}
}
}
if (arrayLen(matches)) {
arrayAppend(issues, {description: check.description, fix: check.fix, matches: matches});

var matches = [];
for (var filePath in filesToScan) {
var content = fileRead(filePath);
var lines = listToArray(content, chr(10), true);
for (var lineNum = 1; lineNum <= arrayLen(lines); lineNum++) {
if (reFindNoCase(check.pattern, lines[lineNum])) {
var relPath = replace(filePath, variables.projectRoot & "/", "");
arrayAppend(matches, "#relPath#:#lineNum#");
}
}
}

// `absent: true` inverts the check — warn when the pattern
// is NOT found anywhere in the scanned set. Used for "you
// should be setting csrfEncryptionKey somewhere" style
// checks. If nothing was scannable (e.g. config/ missing),
// treat as pass to avoid noisy false positives.
var isAbsent = structKeyExists(check, "absent") && check.absent;
if (isAbsent) {
if (!arrayLen(filesToScan) || arrayLen(matches)) {
arrayAppend(passed, check.description);
} else {
var hint = structKeyExists(check, "scanDir") && len(check.scanDir)
? check.scanDir & "/ (no occurrences found)"
: "(no occurrences found)";
arrayAppend(issues, {description: check.description, fix: check.fix, matches: [hint]});
}
} else {
arrayAppend(passed, check.description);
if (arrayLen(matches)) {
arrayAppend(issues, {description: check.description, fix: check.fix, matches: matches});
} else {
arrayAppend(passed, check.description);
}
}
}
}
Expand Down
95 changes: 95 additions & 0 deletions vendor/wheels/tests/specs/cli/UpgradeCheckCoverageSpec.cfc
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* Regression for issue ##2628: `wheels upgrade check --to=4.0.0` for
* `currentMajor <= 3 && targetMajor >= 4` in cli/lucli/Module.cfc scans
* only 3 of the 11 documented breakers from the canonical 3.x → 4.0
* guide. A 3.x user can run the check, see the WireBox bootstrap, the
* plugin folder, and the test base class flagged, upgrade, and then
* hit one of the unscanned breakers in production.
*
* The spec inspects the source of Module.cfc statically and asserts
* that each documented breaker pattern appears in the 3.x -> 4.x
* checks block. This avoids instantiating the CLI Module (which
* depends on the LuCLI `modules.BaseModule` runtime) and keeps the
* assertion close to the data we ship.
*
* Canonical reference: web/sites/guides/src/content/docs/v4-0-0/upgrading/3x-to-4x.mdx
*/
component extends="wheels.WheelsTest" {

function run() {

describe("wheels upgrade check 3.x -> 4.x breaker coverage", () => {

// expandPath("/wheels") resolves to vendor/wheels via the
// configured Lucee mapping; the repo root is two levels above.
var repoRoot = expandPath("/wheels/../..");
var modulePath = repoRoot & "/cli/lucli/Module.cfc";

it("Module.cfc exists at the expected path", () => {
expect(fileExists(modulePath)).toBeTrue("Missing: " & modulePath);
});

// Slice out the 3.x -> 4.x branch so assertions don't accidentally
// match the 2.x -> 3.x checks (which also reference wheels.Test,
// the legacy plugin directory, etc.).
var block = "";
if (fileExists(modulePath)) {
var moduleSource = fileRead(modulePath);
var start = find("currentMajor <= 3 && targetMajor >= 4", moduleSource);
if (start > 0) {
var endIdx = find("// Run checks", moduleSource, start);
var sliceLen = endIdx > 0 ? endIdx - start : len(moduleSource) - start + 1;
block = sliceLen > 0 ? mid(moduleSource, start, sliceLen) : "";
}
}

it("scans for CORS default flip (deny-all) — bare wheels.middleware.Cors()", () => {
expect(findNoCase("wheels.middleware.Cors", block) > 0).toBeTrue(
"3.x -> 4.x checks should grep config/ for new wheels.middleware.Cors() without allowOrigins (CHANGELOG ##2039)."
);
});

it("scans for RateLimiter without explicit trustProxy/proxyStrategy", () => {
expect(findNoCase("RateLimiter", block) > 0).toBeTrue(
"3.x -> 4.x checks should grep config/ for RateLimiter middleware missing trustProxy/proxyStrategy (CHANGELOG ##2024, ##2088)."
);
});

it("scans for allowEnvironmentSwitchViaUrl=true", () => {
expect(findNoCase("allowEnvironmentSwitchViaUrl", block) > 0).toBeTrue(
"3.x -> 4.x checks should grep config/ for allowEnvironmentSwitchViaUrl=true (CHANGELOG ##2076)."
);
});

it("scans for missing csrfEncryptionKey configuration", () => {
expect(findNoCase("csrfEncryptionKey", block) > 0).toBeTrue(
"3.x -> 4.x checks should detect a missing csrfEncryptionKey in config/ (CHANGELOG ##2054, ##2079)."
);
});

it("scans for legacy 'wheels snippets' invocations in build/CI scripts", () => {
expect(findNoCase("wheels snippets", block) > 0).toBeTrue(
"3.x -> 4.x checks should grep build scripts and CI for the legacy 'wheels snippets' command, renamed to 'wheels generate snippets' (CHANGELOG ##1852)."
);
});

it("scans for legacy tests/specs/functions/ directory", () => {
expect(findNoCase("tests/specs/functions", block) > 0).toBeTrue(
"3.x -> 4.x checks should detect the legacy tests/specs/functions/ directory, renamed to functional/ (CHANGELOG ##1872)."
);
});

it("scans for Vite asset helpers (manifest strictness default flip)", () => {
var hasVite = findNoCase("viteScriptTag", block) > 0
|| findNoCase("viteStyleTag", block) > 0
|| findNoCase("vitePreloadTag", block) > 0;
expect(hasVite).toBeTrue(
"3.x -> 4.x checks should grep views for viteScriptTag/viteStyleTag/vitePreloadTag (viteStrictManifest default flip, CHANGELOG ##2133)."
);
});

});

}

}
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,15 @@ The checks are hard-coded in the CLI and keyed by the major-version transition:
- Existence of `plugins/` at the project root (deprecated plugin location).
- `extends="wheels.Test"` anywhere under `tests/`.
- `application.wirebox` references anywhere under `app/` — these must move to `service()` / `inject()` on the new DI container.

Each grep scans `.cfc` and `.cfm` files (as appropriate for the check) recursively and reports `path:lineNumber` for every hit. Directory checks report only the top-level path when the directory exists and is non-empty.
- `new wheels.middleware.Cors()` without `allowOrigins` in `config/` — the 4.0 default changed from wildcard to deny-all (#2039).
- `new wheels.middleware.RateLimiter` in `config/` — prompts the user to verify `trustProxy` and `proxyStrategy`, whose defaults hardened in 4.0 (#2024, #2088).
- `allowEnvironmentSwitchViaUrl=true` in `config/` — the production default flipped to `false` (#2076).
- Missing `csrfEncryptionKey` in `config/` — when absent the key auto-generates and CSRF cookies rotate on every deploy (#2054).
- Legacy `wheels snippets` in `Makefile`, `package.json`, `.github/workflows/`, and top-level `*.sh` files — renamed to `wheels generate snippets` (#1852).
- Existence of `tests/specs/functions/` — renamed to `tests/specs/functional/` (#1872).
- `viteScriptTag`, `viteStyleTag`, or `vitePreloadTag` in `app/views/` — `viteStrictManifest` now defaults to `true` in production, throwing on missing manifest entries (#2133).

Each grep scan covers the file types relevant to that check (`.cfc` and `.cfm` for config and view checks; `Makefile`, `package.json`, `.yml`, `.yaml`, and `.sh` for the `wheels snippets` check) and reports `path:lineNumber` for every hit. Directory checks report only the top-level path when the directory exists and is non-empty.

<Aside type="note" title="Not an exhaustive migration list">
The scanner catches the patterns that are most commonly missed during a major upgrade — it is not a full migration checklist. Read the release notes for the target version alongside the scan output.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,15 @@ The checks are hard-coded in the CLI and keyed by the major-version transition:
- Existence of `plugins/` at the project root (deprecated plugin location).
- `extends="wheels.Test"` anywhere under `tests/`.
- `application.wirebox` references anywhere under `app/` — these must move to `service()` / `inject()` on the new DI container.

Each grep scans `.cfc` and `.cfm` files (as appropriate for the check) recursively and reports `path:lineNumber` for every hit. Directory checks report only the top-level path when the directory exists and is non-empty.
- `new wheels.middleware.Cors()` without `allowOrigins` in `config/` — the 4.0 default changed from wildcard to deny-all (#2039).
- `new wheels.middleware.RateLimiter` in `config/` — prompts the user to verify `trustProxy` and `proxyStrategy`, whose defaults hardened in 4.0 (#2024, #2088).
- `allowEnvironmentSwitchViaUrl=true` in `config/` — the production default flipped to `false` (#2076).
- Missing `csrfEncryptionKey` in `config/` — when absent the key auto-generates and CSRF cookies rotate on every deploy (#2054).
- Legacy `wheels snippets` in `Makefile`, `package.json`, `.github/workflows/`, and top-level `*.sh` files — renamed to `wheels generate snippets` (#1852).
- Existence of `tests/specs/functions/` — renamed to `tests/specs/functional/` (#1872).
- `viteScriptTag`, `viteStyleTag`, or `vitePreloadTag` in `app/views/` — `viteStrictManifest` now defaults to `true` in production, throwing on missing manifest entries (#2133).

Each grep scan covers the file types relevant to that check (`.cfc` and `.cfm` for config and view checks; `Makefile`, `package.json`, `.yml`, `.yaml`, and `.sh` for the `wheels snippets` check) and reports `path:lineNumber` for every hit. Directory checks report only the top-level path when the directory exists and is non-empty.

<Aside type="note" title="Not an exhaustive migration list">
The scanner catches the patterns that are most commonly missed during a major upgrade — it is not a full migration checklist. Read the release notes for the target version alongside the scan output.
Expand Down
Loading