From 0bf1d3ea6218ea358b6ac6c22cae8358eb07d79f Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Wed, 10 Jun 2026 10:14:33 -0700 Subject: [PATCH 1/2] fix: sweep accumulated reviewer nits from the 2026-06 campaign Behavior fixes: LCase word-form operators in $evaluateLogicalExpression (uppercase EQ threw on Adobe CF); /wheels/cli gate reads the reload password from the form scope only (query-string password satisfied the gate while landing in access logs); dbRollback counts applied migrations by tracked status, not the version<=current heuristic (shared-dev-DB skew); NPE guard for null getErrorStream() in the three CLI HTTP bridge helpers; migrator column cache keys verbatim (no case folding); $getForeignKeys throws on missing adapter instead of emitting unquoted SQL; real \d escapes in $convertToString's dead ISO branch. Plus stale-docblock updates (#2903 references, renderWith/ onlyProvides enforcement notes, debug-panel guide note, .ai finally- loop note, migrator CLAUDE.md cache docs) and spec backfills (waitForText timeout, $get without request.wheels, typed-column outlier defaults, conditional loadRoutesSpec restore, uppercase-EQ conditions, dbDrop/dbRestore stub note). Fixes #2977 Signed-off-by: Peter Amiri --- .ai/wheels/cross-engine-compatibility.md | 2 +- CHANGELOG.md | 1 + cli/lucli/Module.cfc | 18 ++++++++ vendor/wheels/Global.cfc | 10 +++-- vendor/wheels/Public.cfc | 5 ++- vendor/wheels/controller/provides.cfc | 3 ++ vendor/wheels/controller/rendering.cfc | 3 ++ vendor/wheels/migrator/Base.cfc | 16 ++++++- vendor/wheels/migrator/CLAUDE.md | 7 +++ vendor/wheels/model/validations.cfc | 6 ++- vendor/wheels/public/views/cli.cfm | 13 +++++- .../tests/specs/dispatch/InvokeMethodSpec.cfc | 11 +++-- .../global/getSettingRequestScopeSpec.cfc | 33 ++++++++++++++ .../tests/specs/global/loadRoutesSpec.cfc | 21 +++++++-- .../migrator/typedColumnDefaultsSpec.cfc | 45 +++++++++++++++++++ .../tests/specs/model/validationsSpec.cfc | 16 +++++++ .../security/CliEndpointHardeningSpec.cfc | 5 +++ .../wheelstest/BrowserIntegrationSpec.cfc | 11 +++++ .../v4-0-0/digging-deeper/debug-panel.mdx | 4 ++ 19 files changed, 211 insertions(+), 19 deletions(-) create mode 100644 vendor/wheels/tests/specs/global/getSettingRequestScopeSpec.cfc create mode 100644 vendor/wheels/tests/specs/migrator/typedColumnDefaultsSpec.cfc diff --git a/.ai/wheels/cross-engine-compatibility.md b/.ai/wheels/cross-engine-compatibility.md index 872a218e21..d4d9724325 100644 --- a/.ai/wheels/cross-engine-compatibility.md +++ b/.ai/wheels/cross-engine-compatibility.md @@ -172,7 +172,7 @@ createDynamicProxy(consumer, ["java.util.function.Consumer"]); ### `for` Loops Inside `finally` Blocks Miscompile on Lucee 7 -Lucee 7.0.1+100 throws `variable [local] doesn't exist` at runtime when a `for` loop declares or iterates `local`-/`var`-scoped variables inside a `finally` block. Isolated with minimal probes: bare assignments and function calls inside `finally` compile and run fine; loops do not. One probe shape even produced a JVM `Expecting a stackmap frame` bytecode-verifier error, pointing at a codegen bug in Lucee's `finally`-block compilation. +Lucee 7.0.1+100 throws `variable [local] doesn't exist` at runtime when a `for` loop declares or iterates `local`-/`var`-scoped variables inside a `finally` block. Both loop forms are affected — `for (init; cond; step)` and `for (item in collection)`. Isolated with minimal probes: bare assignments and function calls inside `finally` compile and run fine; loops do not. One probe shape even produced a JVM `Expecting a stackmap frame` bytecode-verifier error, pointing at a codegen bug in Lucee's `finally`-block compilation. ```cfm // WRONG — crashes at runtime on Lucee 7 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fd38599c3..4f39e3c262 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ All historical references to "CFWheels" in this changelog have been preserved fo ### Fixed +- Reviewer-nit sweep from the 2026-06 remediation campaign (#2977). Behavior fixes: conditional validations with uppercase word-form operators (`condition="1 EQ 0"`) no longer throw on Adobe CF (`$evaluateLogicalExpression` now lowercases the operator); the `/wheels/cli` mutation gate reads the reload password from the form scope ONLY, so a `?password=...` query string can no longer satisfy the gate while logging the password in access logs; `dbRollback` over the `/wheels/cli` bridge counts applied migrations by tracked `status` instead of the `version <= current` heuristic, so peer-applied versions on a shared dev database no longer skew `steps=N`; the CLI's three HTTP bridge helpers guard against `getErrorStream()` returning Java null on bodiless 4xx/5xx responses (was an NPE surfacing as a useless "null" error); the migrator's per-request column cache keys on the verbatim table name (case-folding let `Authors`/`authors` share a slot on case-sensitive databases); `$getForeignKeys()` throws `Wheels.Migrator.MissingAdapter` instead of silently interpolating an unquoted table name when the adapter is missing; and the dead ISO-date fallback branch in `$convertToString` uses real `\d` regex escapes. Plus assorted stale-docblock/comment updates (#2903 references, `renderWith`/`onlyProvides` enforcement notes, debug-panel guide note) and spec backfills (`waitForText` timeout surface, `$get()` without `request.wheels`, typed-column outlier defaults, conditional spec-state restore) (#2977) - `app-runner.cfm` now routes both the test-DB swap and the `finally`-restore through `TestDbResolver.applyDataSource()`, which clears `application.wheels.models` so cached model classes re-initialize against the correct datasource. Without the cache clear, models initialized by a prior dev request kept reading and writing the dev database for the entire test run — spec teardowns like `deleteAll()` in `beforeEach` could wipe real dev data. The restore-side clear matters equally: without it, post-test dev requests silently hit the test datasource via classes cached during the run (#2969) - `mcpHiddenTools()` now structurally appends every `$`-prefixed PUBLIC function discovered via `getMetaData(this)` to the hidden list, in addition to the explicit literal entries. Defense-in-depth: a future `$publicHelper` added without a denylist update can no longer accidentally leak as a callable MCP tool. The literal `$normalizeTestFilter` / `$resolveAppTestDataSource` entries are retained for clarity and the case where LuCLI consults the list before metadata is fully populated; the structural pass de-duplicates and catches additions (#2963). - Dispatch now caches resolved route-scoped string middleware as application-scope singletons keyed by component path, so stateful middleware (e.g. an in-memory `RateLimiter` registered on a `.scope(path="/api", middleware=[...])`) accumulates state across requests instead of getting a fresh, empty instance per request. `$copyRouteForRequest` shallow-copies the route's `middleware` array instead of `Duplicate()`-ing it so Adobe CF (which clones CFCs inside arrays) doesn't silently reset the cached instances. The preflight-capability boolean is now computed once at `$init` and stored on the Dispatch instance, replacing the per-OPTIONS-request `IsInstanceOf` scan over the global pipeline. Documents the singleton lifecycle contract: middleware components must be safe to share across concurrent requests, which every built-in middleware already is (#2954) diff --git a/cli/lucli/Module.cfc b/cli/lucli/Module.cfc index ec836caf7e..0817c6ce88 100644 --- a/cli/lucli/Module.cfc +++ b/cli/lucli/Module.cfc @@ -6340,6 +6340,12 @@ component extends="modules.BaseModule" { var responseCode = conn.getResponseCode(); var inputStream = responseCode >= 400 ? conn.getErrorStream() : conn.getInputStream(); + // getErrorStream() returns Java null on a bodiless 4xx/5xx response; + // Scanner.init(null) NPEs on Lucee and surfaces as a useless "null" + // error message (#2947 review, #2977). No body — return empty. + if (isNull(inputStream)) { + return ""; + } var scanner = createObject("java", "java.util.Scanner").init(inputStream, "UTF-8"); var response = ""; while (scanner.hasNextLine()) { @@ -6373,6 +6379,12 @@ component extends="modules.BaseModule" { var responseCode = conn.getResponseCode(); var inputStream = responseCode >= 400 ? conn.getErrorStream() : conn.getInputStream(); + // getErrorStream() returns Java null on a bodiless 4xx/5xx response; + // Scanner.init(null) NPEs on Lucee and surfaces as a useless "null" + // error message (#2947 review, #2977). No body — return empty. + if (isNull(inputStream)) { + return ""; + } var scanner = createObject("java", "java.util.Scanner").init(inputStream, "UTF-8"); var response = ""; while (scanner.hasNextLine()) { @@ -6403,6 +6415,12 @@ component extends="modules.BaseModule" { // Read response (handle both success and error streams) var responseCode = conn.getResponseCode(); var inputStream = responseCode >= 400 ? conn.getErrorStream() : conn.getInputStream(); + // getErrorStream() returns Java null on a bodiless 4xx/5xx response; + // Scanner.init(null) NPEs on Lucee and surfaces as a useless "null" + // error message (#2947 review, #2977). No body — return empty. + if (isNull(inputStream)) { + return ""; + } var scanner = createObject("java", "java.util.Scanner").init(inputStream, "UTF-8"); var response = ""; while (scanner.hasNextLine()) { diff --git a/vendor/wheels/Global.cfc b/vendor/wheels/Global.cfc index 740b94d482..0c06321957 100644 --- a/vendor/wheels/Global.cfc +++ b/vendor/wheels/Global.cfc @@ -2880,9 +2880,13 @@ return local.$wheels; // fallback parsing attempts for common formats // 1) ISO YYYY-MM-DD[ hh[:mm[:ss]]] - if (ReFind("(?i)^(\\d{4})-(\\d{2})-(\\d{2})(?:[ T](\\d{1,2}):(\\d{2})(?::(\\d{2}))?)?$", local.s2)) { - local.parts = ReReplace(local.s2, "^(\\d{4})-(\\d{2})-(\\d{2}).*$", "\\1-\\2-\\3", "all"); - local.timePart = ReReplace(local.s2, ".*[ T](\\d{1,2}:\\d{2}(?::\\d{2})?).*$", "\\1", "all"); + // Single-backslash escapes: in CFML "\\d" is a literal + // backslash + d in the compiled regex, which never matches a + // digit — the branch was dead. Mirrors the already-fixed + // slash-format branch below (#2933 carry-forward, #2977). + if (ReFind("(?i)^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{1,2}):(\d{2})(?::(\d{2}))?)?$", local.s2)) { + local.parts = ReReplace(local.s2, "^(\d{4})-(\d{2})-(\d{2}).*$", "\1-\2-\3", "all"); + local.timePart = ReReplace(local.s2, ".*[ T](\d{1,2}:\d{2}(?::\d{2})?).*$", "\1", "all"); if (Len(local.timePart) AND local.timePart NEQ local.s2) { // has time local.dt = ParseDateTime(local.parts & " " & local.timePart); diff --git a/vendor/wheels/Public.cfc b/vendor/wheels/Public.cfc index 3bfc78ebcc..81bc444bac 100644 --- a/vendor/wheels/Public.cfc +++ b/vendor/wheels/Public.cfc @@ -228,8 +228,9 @@ component output="false" displayName="Internal GUI" extends="wheels.Global" { /** * Returns a struct { packages: [...], error: "" } populated from the - * wheels-packages registry. Short-circuits in production (defense in - * depth — the handler is already $blockInProduction()-gated). Captures + * wheels-packages registry. Short-circuits outside development (defense in + * depth — the handler is already $blockInProduction()-gated, which since + * #2903 is a development-only allowlist). Captures * any registry error into the `error` field so the view can render a * friendly banner instead of a stack trace. * diff --git a/vendor/wheels/controller/provides.cfc b/vendor/wheels/controller/provides.cfc index 5cef302b96..56dab58985 100644 --- a/vendor/wheels/controller/provides.cfc +++ b/vendor/wheels/controller/provides.cfc @@ -30,6 +30,9 @@ component { /** * Use this in an individual controller action to define which formats the action will respond with. * This can be used to define provides behavior in individual actions or to override a global setting set with `provides` in the controller's `config()`. + * Restrictions are enforced (since 4.0.4): `renderWith()` falls back to the `html` view for a + * format outside the list, and the automatic render in `$callAction()` skips view rendering for + * non-acceptable, non-html formats. * * [section: Controller] * [category: Provides Functions] diff --git a/vendor/wheels/controller/rendering.cfc b/vendor/wheels/controller/rendering.cfc index 33f76a087b..e53e2fa031 100644 --- a/vendor/wheels/controller/rendering.cfc +++ b/vendor/wheels/controller/rendering.cfc @@ -176,6 +176,9 @@ component { * Instructs the controller to render the data passed in to the format that is requested. * If the format requested is `json` or `xml`, Wheels will transform the data into that format automatically. * For other formats (or to override the automatic formatting), you can also create a view template in this format: `nameofaction.xml.cfm`, `nameofaction.json.cfm`, `nameofaction.pdf.cfm`, etc. + * Per-action format restrictions set with `onlyProvides()` are enforced here (since 4.0.4): + * when the requested format is not acceptable for the action, `renderWith()` falls back to + * rendering the `html` view — even when `html` itself is not in the `onlyProvides()` list. * * [section: Controller] * [category: Rendering Functions] diff --git a/vendor/wheels/migrator/Base.cfc b/vendor/wheels/migrator/Base.cfc index 38fc19a0c6..128b2611cf 100644 --- a/vendor/wheels/migrator/Base.cfc +++ b/vendor/wheels/migrator/Base.cfc @@ -109,7 +109,16 @@ component extends="wheels.Global"{ // rather than assigning a bare local inside catch: BoxLang discards // `local.X = ...` assignments made in a catch body.) local.state = {tableExists = true}; - local.quotedTable = StructKeyExists(this, "adapter") ? this.adapter.quoteTableName(arguments.table) : arguments.table; + // Migration.init() always sets this.adapter, so a missing adapter is a + // broken instantiation — fail loudly rather than silently interpolating + // an UNQUOTED table name into SQL (#2937 review, #2977). + if (!StructKeyExists(this, "adapter")) { + Throw( + type = "Wheels.Migrator.MissingAdapter", + message = "$getForeignKeys() requires an initialized database adapter. Instantiate migrations through Migration.init()." + ); + } + local.quotedTable = this.adapter.quoteTableName(arguments.table); try { $query( datasource = application[local.appKey].dataSourceName, @@ -206,7 +215,10 @@ component extends="wheels.Global"{ // would otherwise issue a full table-metadata round-trip per row. // $execute() drops the cache whenever a statement runs, so DDL in the // same request (addColumn() etc.) is reflected on the next read. - local.cacheKey = LCase(application[local.appKey].dataSourceName & "|" & arguments.tableName); + // Key on the VERBATIM table name: the $dbinfo probe below uses original + // case, so case-folding the key would let `Authors` and `authors` share + // one slot on case-sensitive databases (#2937 review, #2977). + local.cacheKey = application[local.appKey].dataSourceName & "|" & arguments.tableName; if ( StructKeyExists(request, "$wheelsMigratorColumns") && StructKeyExists(request.$wheelsMigratorColumns, local.cacheKey) diff --git a/vendor/wheels/migrator/CLAUDE.md b/vendor/wheels/migrator/CLAUDE.md index 538f28b68a..becbcb60dd 100644 --- a/vendor/wheels/migrator/CLAUDE.md +++ b/vendor/wheels/migrator/CLAUDE.md @@ -58,6 +58,13 @@ The flag is read via `$get("useUnderscoreReferenceColumns")` inside `references( 2. **Hard-coding `& "id"` or `& "type"` concatenations.** All four sites in this directory resolve the reference-column suffix through `$get("useUnderscoreReferenceColumns")` — `TableDefinition.cfc::references()` (id + polymorphic type), `Migration.cfc::removeColumn` (referenceName branch), and `Migration.cfc::addReference`. If you add new code that builds a reference column name, route it through `$get` too rather than hard-coding `& "id"`. 3. **`required` on column-name parameters.** Use `$combineArguments(... required=true)` instead. Declaring CFML-level `required` blocks the alias path because validation runs before the function body. +## Internal caches + +Two caches introduced in #2937 — know their scopes before adding probes: + +- `application[appKey].$migratorAdapterNames` — application-scoped, keyed by datasource name. Memoized migrator adapter name, written by `Base.cfc::$getDBType()`. Survives requests; rebuilt on reload (a datasource's driver can't change without one). +- `request.$wheelsMigratorColumns` — request-scoped, keyed by `dsName|tableName` (table name VERBATIM — no case folding, since the `$dbinfo` probe uses original case and case-sensitive databases can host `Authors` and `authors` separately). Column list per table, written by `Base.cfc::$getColumns()`, dropped wholesale by `$execute()` so DDL in the same request is reflected on the next read. + ## Tests Specs live in `vendor/wheels/tests/specs/migrator/`. `referencesSpec.cfc` exercises `TableDefinition::references()` (the `columnNames` alias plus the suffix flag) at the unit layer — inspecting `t.columns` / `t.foreignKeys` directly without `t.create()` so the assertions are adapter-independent. `primaryKeySpec.cfc` mirrors that shape for `TableDefinition::primaryKey()` — the `columnName` / `columnNames` aliases plus precedence semantics (#2803). `migrationSpec.cfc` covers Migration.cfc command-version helpers via real DDL roundtrips — its "Tests addReference" describe block guards the `useUnderscoreReferenceColumns` path on `Migration.cfc::addReference()`. Most FK-related tests in `migrationSpec.cfc` skip on SQLite (which doesn't support altering CONSTRAINTS) but run on every other engine in CI. diff --git a/vendor/wheels/model/validations.cfc b/vendor/wheels/model/validations.cfc index 7a5a10da35..53aa704755 100644 --- a/vendor/wheels/model/validations.cfc +++ b/vendor/wheels/model/validations.cfc @@ -962,7 +962,11 @@ component { } local.leftOperand = IsNumeric(local.tokens[1]) ? JavaCast("double", local.tokens[1]) : local.tokens[1]; local.rightOperand = IsNumeric(local.tokens[3]) ? JavaCast("double", local.tokens[3]) : local.tokens[3]; - return $resolveOperator(local.leftOperand, local.rightOperand, local.tokens[2]); + // LCase keeps word-form operators ("1 EQ 0") compatible with the + // case-sensitive switch in $resolveOperator on Adobe CF — symbolic + // operators are already lowercased by $normalizeConditionOperators, + // but word-form ones arrive raw (#2977). + return $resolveOperator(local.leftOperand, local.rightOperand, LCase(local.tokens[2])); } /** diff --git a/vendor/wheels/public/views/cli.cfm b/vendor/wheels/public/views/cli.cfm index a320f608af..5ff03e12db 100644 --- a/vendor/wheels/public/views/cli.cfm +++ b/vendor/wheels/public/views/cli.cfm @@ -16,7 +16,11 @@ try { requestMethod = cgi.request_method, remoteAddr = cgi.remote_addr, forwardedFor = cgi.http_x_forwarded_for, - password = StructKeyExists(request.wheels.params, "password") ? request.wheels.params.password : "" + // Form scope ONLY: request.wheels.params merges URL + form, so a + // ?password=... query string would satisfy the gate while logging + // the reload password in access logs / proxies — contradicting the + // SEC-4 design of carrying it as a form field (#2947 review, #2977). + password = StructKeyExists(form, "password") ? form.password : "" ); if (!local.gate.allowed) { cfheader(statuscode = local.gate.statusCode); @@ -318,9 +322,14 @@ try { // Find target version based on steps. Reuses the list // discovered in the preamble instead of re-discovering. + // Filter on tracked status, not version <= current: on a shared + // dev DB a peer-applied version above your latest local file + // made the version heuristic count pending/orphan rows as + // applied, so `steps=N` rolled back fewer real migrations + // (same P3 fix dbStatus got in #2947; #2977). local.appliedMigrations = []; for (local.migration in data.migrations) { - if (local.migration.version <= data.currentVersion) { + if (local.migration.status == "migrated") { arrayAppend(local.appliedMigrations, local.migration); } } diff --git a/vendor/wheels/tests/specs/dispatch/InvokeMethodSpec.cfc b/vendor/wheels/tests/specs/dispatch/InvokeMethodSpec.cfc index cdaafb2485..06a7b8e0c9 100644 --- a/vendor/wheels/tests/specs/dispatch/InvokeMethodSpec.cfc +++ b/vendor/wheels/tests/specs/dispatch/InvokeMethodSpec.cfc @@ -42,11 +42,14 @@ component extends="wheels.WheelsTest" { it("invokes a Public.cfc instance without throwing on $blockInProduction", function() { // End-to-end shape of the dispatch flow at Dispatch.cfc:287. // We don't actually serve a request — we just verify the - // adapter can invoke a Public.cfc handler. In non-production - // environments $blockInProduction() short-circuits to a no-op, - // so the only thing we're testing is "did the receiver survive + // adapter can invoke a Public.cfc handler. In the development + // environment $blockInProduction() short-circuits to a no-op + // (since #2903 the gate is a development-only allowlist), so + // the only thing we're testing is "did the receiver survive // the dispatch?" If it didn't, the call throws before the - // include statement runs. + // include statement runs. (This spec invokes the ungated + // index() handler, so the production-only early-return below + // is belt-and-suspenders.) if ( StructKeyExists(application, "wheels") && StructKeyExists(application.wheels, "environment") diff --git a/vendor/wheels/tests/specs/global/getSettingRequestScopeSpec.cfc b/vendor/wheels/tests/specs/global/getSettingRequestScopeSpec.cfc new file mode 100644 index 0000000000..cbe11bf0ab --- /dev/null +++ b/vendor/wheels/tests/specs/global/getSettingRequestScopeSpec.cfc @@ -0,0 +1,33 @@ +/** + * Regression surface of the DC16 fix (#2933): $get()'s per-tenant override + * lookup traverses request.wheels.tenant.config via a StructKeyExists chain. + * The exact hazard the IsDefined→StructKeyExists rewrite guarded against is + * an ABSENT request.wheels (early bootstrap, CLI call sites) — the happy + * tenant-override paths are covered by MultiTenantIntegrationSpec; this + * pins the no-throw contract for the absent case (#2977). + */ +component extends="wheels.WheelsTest" { + + function run() { + + describe("$get() without request.wheels", () => { + + it("does not throw when request.wheels is absent", () => { + var had = StructKeyExists(request, "wheels"); + var saved = had ? request.wheels : {}; + StructDelete(request, "wheels"); + try { + var value = application.wo.$get("environment"); + expect(value).toBe(application.wheels.environment); + } finally { + if (had) { + request.wheels = saved; + } + } + }); + + }); + + } + +} diff --git a/vendor/wheels/tests/specs/global/loadRoutesSpec.cfc b/vendor/wheels/tests/specs/global/loadRoutesSpec.cfc index 801ce64b46..80029a07d6 100644 --- a/vendor/wheels/tests/specs/global/loadRoutesSpec.cfc +++ b/vendor/wheels/tests/specs/global/loadRoutesSpec.cfc @@ -2,14 +2,27 @@ component extends="wheels.WheelsTest" { function beforeAll() { _originalRoutes = Duplicate(application.wheels.routes) - _originalStaticRoutes = StructKeyExists(application.wheels, "staticRoutes") ? StructCopy(application.wheels.staticRoutes) : {} - _originalNamedRoutePositions = StructKeyExists(application.wheels, "namedRoutePositions") ? StructCopy(application.wheels.namedRoutePositions) : {} + _hadStaticRoutes = StructKeyExists(application.wheels, "staticRoutes") + _originalStaticRoutes = _hadStaticRoutes ? StructCopy(application.wheels.staticRoutes) : {} + _hadNamedRoutePositions = StructKeyExists(application.wheels, "namedRoutePositions") + _originalNamedRoutePositions = _hadNamedRoutePositions ? StructCopy(application.wheels.namedRoutePositions) : {} } function afterAll() { application.wheels.routes = _originalRoutes - application.wheels.staticRoutes = _originalStaticRoutes - application.wheels.namedRoutePositions = _originalNamedRoutePositions + // Restore only what existed: an unconditional assignment would leave a + // spurious empty key behind when the spec ran before the app ever + // populated these caches (#2933 review, #2977). + if (_hadStaticRoutes) { + application.wheels.staticRoutes = _originalStaticRoutes + } else { + StructDelete(application.wheels, "staticRoutes") + } + if (_hadNamedRoutePositions) { + application.wheels.namedRoutePositions = _originalNamedRoutePositions + } else { + StructDelete(application.wheels, "namedRoutePositions") + } } function run() { diff --git a/vendor/wheels/tests/specs/migrator/typedColumnDefaultsSpec.cfc b/vendor/wheels/tests/specs/migrator/typedColumnDefaultsSpec.cfc new file mode 100644 index 0000000000..6c2c93def2 --- /dev/null +++ b/vendor/wheels/tests/specs/migrator/typedColumnDefaultsSpec.cfc @@ -0,0 +1,45 @@ +/** + * Spot-check that the per-type outlier parameter defaults survived the + * $addTypedColumns helper-dedup refactor (#2937 review, #2977). + * + * Most typed column helpers declare `string default` / `boolean allowNull` + * with NO default value; `float()` is the long-standing outlier with + * `default=""` / `allowNull=true` (preserved for backward compatibility — + * addColumnOptions renders default="" as DEFAULT NULL). A future cleanup + * that "harmonizes" the signatures would silently change emitted DDL; this + * spec pins the divergence on the built column definition itself. + */ +component extends="wheels.WheelsTest" { + + function beforeAll() { + variables.migration = CreateObject("component", "wheels.migrator.Migration").init(); + } + + function run() { + + describe("TableDefinition typed-column outlier defaults", () => { + + it("float() applies its default='' / allowNull=true outlier defaults", () => { + var t = variables.migration.createTable(name = "dbm_typed_defaults_test", force = true); + t.float(columnNames = "ratio"); + + expect(ArrayLen(t.columns)).toBe(1); + expect(t.columns[1].type).toBe("float"); + expect(t.columns[1]).toHaveKey("default"); + expect(t.columns[1]["default"]).toBe(""); + expect(t.columns[1].allowNull).toBeTrue(); + }); + + it("integer() does not inherit float()'s outlier defaults", () => { + var t = variables.migration.createTable(name = "dbm_typed_defaults_test2", force = true); + t.integer(columnNames = "age"); + + expect(ArrayLen(t.columns)).toBe(1); + expect(t.columns[1].type).toBe("integer"); + }); + + }); + + } + +} diff --git a/vendor/wheels/tests/specs/model/validationsSpec.cfc b/vendor/wheels/tests/specs/model/validationsSpec.cfc index db9961caf1..e3d6d1335e 100644 --- a/vendor/wheels/tests/specs/model/validationsSpec.cfc +++ b/vendor/wheels/tests/specs/model/validationsSpec.cfc @@ -173,6 +173,22 @@ component extends="wheels.WheelsTest" { assert_test(user, true) }) + it("if validation using uppercase word-form operator", () => { + // Word-form operators arrive raw in $evaluateLogicalExpression + // (only symbolic operators are pre-lowercased) — uppercase EQ + // used to hit the case-sensitive switch in $resolveOperator on + // Adobe CF and throw (##2977). + args.condition = "1 EQ 1" + user.validatesLengthOf(argumentCollection = args) + assert_test(user, false) + }) + + it("unless validation using uppercase word-form operator", () => { + args.unless = "1 EQ 1" + user.validatesLengthOf(argumentCollection = args) + assert_test(user, true) + }) + it("if validation using method invalid", () => { args.condition = "isnew()" user.validatesLengthOf(argumentCollection = args) diff --git a/vendor/wheels/tests/specs/security/CliEndpointHardeningSpec.cfc b/vendor/wheels/tests/specs/security/CliEndpointHardeningSpec.cfc index 8108cfffe9..3eb6b65027 100644 --- a/vendor/wheels/tests/specs/security/CliEndpointHardeningSpec.cfc +++ b/vendor/wheels/tests/specs/security/CliEndpointHardeningSpec.cfc @@ -79,6 +79,11 @@ component extends="wheels.WheelsTest" { "dbVersion", "dbSchema", "dbShell", + // dbDrop and dbRestore are currently STUBS that return + // "use your database tools" messages (cli.cfm), so the + // read-only classification is deliberate. If either is + // ever implemented, it must move to the mutating list + // and pass $cliMutationGateCheck (#2947 review, #2977). "dbDrop", "dbRestore", "routes", diff --git a/vendor/wheels/tests/specs/wheelstest/BrowserIntegrationSpec.cfc b/vendor/wheels/tests/specs/wheelstest/BrowserIntegrationSpec.cfc index 7e153a6f28..4ef80be17a 100644 --- a/vendor/wheels/tests/specs/wheelstest/BrowserIntegrationSpec.cfc +++ b/vendor/wheels/tests/specs/wheelstest/BrowserIntegrationSpec.cfc @@ -70,6 +70,17 @@ component extends="wheels.WheelsTest" { c.waitForUrl(url="**/never", seconds=5); }).toThrow(type="Wheels.BrowserTimeoutUnavailable"); }); + + it("waitForText() with a custom timeout but no launcher surfaces BrowserTimeoutUnavailable", () => { + // Routes through the same $waitOptions helper as waitFor()/ + // waitForUrl() — covered so a refactor of that helper can't + // silently regress one of the three (#2934 review, #2977). + var c = new wheels.wheelstest.BrowserClient() + .init(baseUrl="http://localhost"); + expect(() => { + c.waitForText(text="never", seconds=5); + }).toThrow(type="Wheels.BrowserTimeoutUnavailable"); + }); }); describe("BrowserClient — launcher wiring", () => { diff --git a/web/sites/guides/src/content/docs/v4-0-0/digging-deeper/debug-panel.mdx b/web/sites/guides/src/content/docs/v4-0-0/digging-deeper/debug-panel.mdx index 4129951f8e..bb46df3cd1 100644 --- a/web/sites/guides/src/content/docs/v4-0-0/digging-deeper/debug-panel.mdx +++ b/web/sites/guides/src/content/docs/v4-0-0/digging-deeper/debug-panel.mdx @@ -180,6 +180,10 @@ All settings are applied in `config/settings.cfm` or an environment-specific fil ``` +:::note +Setting `enablePublicComponent=true` outside the `development` environment shows the Tools tab, but every Tools link (`/wheels/info`, `/wheels/routes`, the test runners, migrator, packages, …) returns **404**: since Wheels 4.0.3 those handlers are gated by a development-only allowlist, regardless of this setting. Only `environment="development"` can reach them. +::: + | Setting | Default | Effect | |---|---|---| | `showDebugInformation` | `true` in `development`, `false` elsewhere | Show or hide the debug bar entirely | From 18aa5eefd456e2f834b6c8b4314f49e4b9b3f1c4 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Wed, 10 Jun 2026 10:59:43 -0700 Subject: [PATCH 2/2] chore(docs): move changelog entry to changelog.d fragment Eliminates the [Unreleased]-anchor merge conflicts across campaign PRs; fragments are assembled into CHANGELOG.md at release promotion. Signed-off-by: Peter Amiri --- CHANGELOG.md | 1 - changelog.d/reviewer-nit-sweep.fixed.md | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 changelog.d/reviewer-nit-sweep.fixed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f39e3c262..1fd38599c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,6 @@ All historical references to "CFWheels" in this changelog have been preserved fo ### Fixed -- Reviewer-nit sweep from the 2026-06 remediation campaign (#2977). Behavior fixes: conditional validations with uppercase word-form operators (`condition="1 EQ 0"`) no longer throw on Adobe CF (`$evaluateLogicalExpression` now lowercases the operator); the `/wheels/cli` mutation gate reads the reload password from the form scope ONLY, so a `?password=...` query string can no longer satisfy the gate while logging the password in access logs; `dbRollback` over the `/wheels/cli` bridge counts applied migrations by tracked `status` instead of the `version <= current` heuristic, so peer-applied versions on a shared dev database no longer skew `steps=N`; the CLI's three HTTP bridge helpers guard against `getErrorStream()` returning Java null on bodiless 4xx/5xx responses (was an NPE surfacing as a useless "null" error); the migrator's per-request column cache keys on the verbatim table name (case-folding let `Authors`/`authors` share a slot on case-sensitive databases); `$getForeignKeys()` throws `Wheels.Migrator.MissingAdapter` instead of silently interpolating an unquoted table name when the adapter is missing; and the dead ISO-date fallback branch in `$convertToString` uses real `\d` regex escapes. Plus assorted stale-docblock/comment updates (#2903 references, `renderWith`/`onlyProvides` enforcement notes, debug-panel guide note) and spec backfills (`waitForText` timeout surface, `$get()` without `request.wheels`, typed-column outlier defaults, conditional spec-state restore) (#2977) - `app-runner.cfm` now routes both the test-DB swap and the `finally`-restore through `TestDbResolver.applyDataSource()`, which clears `application.wheels.models` so cached model classes re-initialize against the correct datasource. Without the cache clear, models initialized by a prior dev request kept reading and writing the dev database for the entire test run — spec teardowns like `deleteAll()` in `beforeEach` could wipe real dev data. The restore-side clear matters equally: without it, post-test dev requests silently hit the test datasource via classes cached during the run (#2969) - `mcpHiddenTools()` now structurally appends every `$`-prefixed PUBLIC function discovered via `getMetaData(this)` to the hidden list, in addition to the explicit literal entries. Defense-in-depth: a future `$publicHelper` added without a denylist update can no longer accidentally leak as a callable MCP tool. The literal `$normalizeTestFilter` / `$resolveAppTestDataSource` entries are retained for clarity and the case where LuCLI consults the list before metadata is fully populated; the structural pass de-duplicates and catches additions (#2963). - Dispatch now caches resolved route-scoped string middleware as application-scope singletons keyed by component path, so stateful middleware (e.g. an in-memory `RateLimiter` registered on a `.scope(path="/api", middleware=[...])`) accumulates state across requests instead of getting a fresh, empty instance per request. `$copyRouteForRequest` shallow-copies the route's `middleware` array instead of `Duplicate()`-ing it so Adobe CF (which clones CFCs inside arrays) doesn't silently reset the cached instances. The preflight-capability boolean is now computed once at `$init` and stored on the Dispatch instance, replacing the per-OPTIONS-request `IsInstanceOf` scan over the global pipeline. Documents the singleton lifecycle contract: middleware components must be safe to share across concurrent requests, which every built-in middleware already is (#2954) diff --git a/changelog.d/reviewer-nit-sweep.fixed.md b/changelog.d/reviewer-nit-sweep.fixed.md new file mode 100644 index 0000000000..b7096d7a8c --- /dev/null +++ b/changelog.d/reviewer-nit-sweep.fixed.md @@ -0,0 +1 @@ +- Reviewer-nit sweep from the 2026-06 remediation campaign (#2977). Behavior fixes: conditional validations with uppercase word-form operators (`condition="1 EQ 0"`) no longer throw on Adobe CF (`$evaluateLogicalExpression` now lowercases the operator); the `/wheels/cli` mutation gate reads the reload password from the form scope ONLY, so a `?password=...` query string can no longer satisfy the gate while logging the password in access logs; `dbRollback` over the `/wheels/cli` bridge counts applied migrations by tracked `status` instead of the `version <= current` heuristic, so peer-applied versions on a shared dev database no longer skew `steps=N`; the CLI's three HTTP bridge helpers guard against `getErrorStream()` returning Java null on bodiless 4xx/5xx responses (was an NPE surfacing as a useless "null" error); the migrator's per-request column cache keys on the verbatim table name (case-folding let `Authors`/`authors` share a slot on case-sensitive databases); `$getForeignKeys()` throws `Wheels.Migrator.MissingAdapter` instead of silently interpolating an unquoted table name when the adapter is missing; and the dead ISO-date fallback branch in `$convertToString` uses real `\d` regex escapes. Plus assorted stale-docblock/comment updates (#2903 references, `renderWith`/`onlyProvides` enforcement notes, debug-panel guide note) and spec backfills (`waitForText` timeout surface, `$get()` without `request.wheels`, typed-column outlier defaults, conditional spec-state restore) (#2977)