feat(testing): add browserLoginAsHandler override for /_browser/login-as fixture#2832
Conversation
…-as fixture
The built-in `/_browser/login-as` fixture hard-coded `session.userId = 1` and
`session.userEmail = params.identifier`, so any real app whose authentication
writes a richer session shape (`session.member = { id, email, firstName,
lastName }` etc.) had to either skip `loadBrowserTestFixtures` and duplicate
the entire route + env-gate, or patch the vendor tree on every upgrade.
Introduces `application.wheels.browserLoginAsHandler` — a
`"Controller##action"` string read at route-registration time and used as
the `/_browser/login-as` target in place of `BrowserTestLogin##create`. The
override controller is a normal Wheels controller with full access to
`params`, `session`, `model()`, and `inject()`. Env-gating extracts from
the framework controller into `wheels.middleware.BrowserTestFixtureGuard`,
attached to the whole `/_browser` scope so the gate still applies when an
app supplies its own handler.
Recommended path from the cross-framework research on #2830: matches
Wheels' existing `to = "Controller##action"` routing convention without
introducing event/signal infrastructure, and stores only a string in
`application.wheels` (no Adobe CF function-member crash).
Fixes #2830
Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
… fixture Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Wheels Bot — Docs updatedAdded a doc commit to this PR:
|
There was a problem hiding this comment.
Wheels Bot — Reviewer A
TL;DR: PR #2832 introduces application.wheels.browserLoginAsHandler as an app-configurable Controller##action string that replaces the hard-coded BrowserTestLogin##create target on /_browser/login-as, backed by a new BrowserTestFixtureGuard middleware that env-gates the whole /_browser scope. The design is sound — it solves the richer-session-shape problem without forking the vendor tree, stores only a string in application.wheels (no Adobe CF function-member issue, per anti-pattern #2), and the middleware approach actually improves the security story for custom handlers. No cross-engine bugs or correctness blockers. Three minor items below.
Correctness
StructCopy vs Duplicate for _originalStaticRoutes
vendor/wheels/tests/specs/wheelstest/BrowserLoginAsHandlerSpec.cfc line 38:
_originalStaticRoutes = StructKeyExists(application.wheels, "staticRoutes")
? StructCopy(application.wheels.staticRoutes)
: {};StructCopy is a shallow copy. If $lockedLoadRoutes() mutates any nested struct or array inside staticRoutes in-place (rather than replacing top-level keys), the saved copy will share the modified references and afterAll() won't fully restore the original state. The other three saved fields (_originalRoutes, _originalNamed) use Duplicate; _originalStaticRoutes should match:
_originalStaticRoutes = StructKeyExists(application.wheels, "staticRoutes")
? Duplicate(application.wheels.staticRoutes)
: {};Tests
No afterEach to normalize browserLoginAsHandler between tests
Each it() block manages its own pre-condition for browserLoginAsHandler (explicit StructDelete or assignment before calling $lockedLoadRoutes()). This works for the four tests here, but a shared afterEach would make isolation automatic and protect any future additions from inheriting stale handler state:
afterEach(() => {
if (StructKeyExists(application.wheels, "browserLoginAsHandler")) {
StructDelete(application.wheels, "browserLoginAsHandler");
}
});The individual tests could then drop their inline cleanup guards.
Security
Controller-level env check removed — now middleware-only
vendor/wheels/public/browser-fixtures/controllers/BrowserTestLogin.cfc:
The PR removes the controller-level if (!ListFindNoCase("testing,development", ...)) guard from create() and delegates entirely to BrowserTestFixtureGuard. The commit message and in-file comment document this intentionally, and the design is defensible: the middleware applies uniformly to every /_browser/* route (including custom handlers) and catches environment changes after route registration. This is an improvement for the custom-handler scenario.
Worth calling out: BrowserTestLogin::create is now fully ungated at the controller level. If it were ever reached outside the /_browser scope's dispatch path — via an internal renderPage() call or an accidental route collision — the middleware would not fire. The existing route-registration gate (routes only mounted in test/dev) is the first line of defense; the middleware is the second. Removing the third layer is not a blocker here, but reviewers should confirm this trade-off is deliberate and that no existing dispatch path can reach BrowserTestLogin::create outside the /_browser route scope.
Commits
Single commit feat(testing): add browserLoginAsHandler override for /_browser/login-as fixture — valid type, sensible scope, 67-char subject, body explains the why, DCO sign-off present. Clean.
Docs
Framework docs, .ai/wheels/, and CLAUDE.md are intentionally deferred to bot-update-docs.yml. The CHANGELOG ### Added entry is in the correct position (before ### Changed\) and the entry is informative.
There was a problem hiding this comment.
Wheels Bot — Reviewer A
TL;DR: PR #2832 (now at 51098097e) adds one docs commit on top of the feat(testing) commit reviewed at 1597613c2. The implementation is unchanged; the new commit adds the MDX guide update and .ai/wheels/ reference doc update that the previous round flagged as deferred. The overall design is sound — application.wheels.browserLoginAsHandler stores only a string, BrowserTestFixtureGuard closes the env-gate gap for custom handlers, and the docs commit accurately reflects the behaviour. The three minor items raised in the first round carry forward.
Correctness
StructCopy vs Duplicate for _originalStaticRoutes (vendor/wheels/tests/specs/wheelstest/BrowserLoginAsHandlerSpec.cfc, line 36)
_originalStaticRoutes = StructKeyExists(application.wheels, "staticRoutes")
? StructCopy(application.wheels.staticRoutes)
: {};StructCopy is a shallow copy. _originalRoutes (line 26) uses Duplicate. The staticRoutes map values are route structs — the same objects appended to application.wheels.routes. $lockedLoadRoutes() replaces application.wheels.routes wholesale with new structs, so the saved shallow-copy references won't be mutated after the call and restoration in afterAll() works today.
This is, however, inconsistent: the spec itself uses Duplicate for _originalRoutes (the bigger array), while sibling specs (mapperSpec.cfc line 7, mapperModernSpec.cfc line 7) both use StructCopy for _originalStaticRoutes — so this spec is following an established codebase pattern, not introducing a regression. Noting for completeness; not a blocker.
Tests
No afterEach to normalize browserLoginAsHandler between tests
The two tests that touch application.wheels.browserLoginAsHandler manage their own pre-condition inline (explicit StructDelete at test start, or direct assignment). A shared afterEach would make isolation automatic and future-proof:
afterEach(() => {
if (StructKeyExists(application.wheels, "browserLoginAsHandler")) {
StructDelete(application.wheels, "browserLoginAsHandler");
}
});Minor — the four tests are correct as written.
Security
Controller-level env check removed — now middleware-only (vendor/wheels/public/browser-fixtures/controllers/BrowserTestLogin.cfc)
The controller-level ListFindNoCase("testing,development", ...) guard in create() was removed and replaced by BrowserTestFixtureGuard. The comment documents this as intentional. The middleware is attached uniformly to the entire /_browser scope, so custom handlers benefit automatically. The route-registration gate in Global.cfc (line 1430–1434) is the first line of defense; the middleware is the second. Removing the controller-level third layer is a defensible trade-off. Reviewers should confirm that no code path other than the /_browser scope can dispatch to BrowserTestLogin::create after this change, particularly via an internal renderPage() call or aliased route.
Docs
The new docs(web/guides) commit (51098097e) accurately reflects the implementation:
web/sites/guides/src/content/docs/v4-0-0/testing/browser-tests.mdx— usage guidance and code example are correct, the empty-string fallback note is accurate, the env-gate delegation explanation is clear..ai/wheels/testing/browser-testing.md— reference doc bullet updated with the setting name, default, override behaviour, and issue reference. Matches the implementation inroutes.cfm.
Both docs correctly show ## escaping for # in CFML string literals inside .cfm code blocks. No issues.
Commits
Two commits in the PR:
feat(testing): add browserLoginAsHandler override for /_browser/login-as fixture— valid type, 67-char subject, body explains the why, DCO sign-off present (Signed-off-by: claude[bot]). Clean.docs(web/guides): document browserLoginAsHandler override for loginAs fixture— valid type, scope matches changed directories, DCO sign-off present. Clean.
Cross-engine
BrowserTestFixtureGuard.cfc uses only StructKeyExists, ListFindNoCase, and Throw — no engine-specific APIs. routes.cfm logic uses StructKeyExists, IsSimpleValue, and Len on a simple string — all engine-neutral. No cross-engine concerns.
Wheels Bot — Reviewer B (round 1)A's review is mostly well-grounded — the middleware security analysis is accurate, the tests note is a reasonable style suggestion, and the docs deferral is correct. One false positive in the correctness finding undermines its stated justification. SycophancyNone detected. False positives
A writes: "The other three saved fields ( The diff shows: // BrowserLoginAsHandlerSpec.cfc — beforeAll()
_originalRoutes = Duplicate(application.wheels.routes); // deep copy ✓
_originalNamed = StructCopy(application.wheels.namedRoutePositions); // shallow copy — NOT Duplicate
_originalStaticRoutes = ... StructCopy(application.wheels.staticRoutes) ...; // shallow copy
The underlying concern — whether Missed issuesNone detected. Cross-engine checks:
Verdict alignmentA's ConvergenceNot aligned. A's correctness finding rests on a false premise ( |
The describe() label embedded the issue reference as "(#2830)" with a lone '#'. CFML treats '#' as an interpolation delimiter, so the unescaped, unclosed '#' threw "Invalid Syntax Closing [#] not found" at compile time. Because core tests compile the entire wheels.tests.specs directory, this one syntax error crashed the whole Lucee 7 bundle (HTTP 500), not just this spec. Escaped to '##2830'. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com>
…n-browser-login-as-set Resolve CHANGELOG.md [Unreleased] Added conflict by keeping both the RustCFML engine-recognition entry (#2837) and the browserLoginAsHandler fixture-override entry (#2830). Brings in #2831/#2853, which repair the CLI test suite masking (the prior 605/5/-8 result). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com>
There was a problem hiding this comment.
Wheels Bot — Reviewer A
TL;DR: PR 2832 (now at b5fc2c5) adds two commits since the round-2 review: a critical hash-escape fix for BrowserLoginAsHandlerSpec.cfc that was crashing the entire Lucee 7 test bundle, and a merge-from-develop that resolves a CHANGELOG conflict cleanly. Both are correct. The fix addresses the one latent defect that would have been a blocker had CI caught it earlier. The minor items from prior rounds carry forward unchanged. Verdict: comment — no new blockers.
Correctness
Hash-escape fix in BrowserLoginAsHandlerSpec.cfc describe label (d57e4d8, line 55)
The describe label embedded an unescaped hash character inside a CFML string literal. CFML treats the hash as an expression delimiter; an unclosed hash throws 'Invalid Syntax Closing [hash] not found' at compile time. Because core tests load every CFC in wheels.tests.specs in a single bundle, one bad file crashes the entire suite for that engine, not just the containing spec. This is documented in CLAUDE.md under 'Test-specific gotchas'. The fix correctly changes (hash2830) to (hashhash2830) at line 55. The commit message accurately describes the bundle-wide crash scope. DCO sign-off is present (Signed-off-by: Peter Amiri).
Merge conflict resolution (b5fc2c5)
The CHANGELOG conflict keeps both the RustCFML entry (from develop / issue 2837) and the browserLoginAsHandler entry (from this branch / issue 2830) under 'Added'. Both entries are factually accurate. No stray conflict markers remain.
Tests
Two minor items from rounds 1-2 carry forward (no new test changes in these commits):
- StructCopy vs Duplicate for _originalStaticRoutes (line 36): Matches the pattern in sibling specs (mapperSpec.cfc, mapperModernSpec.cfc) and is safe because lockedLoadRoutes() replaces application.wheels.routes wholesale. Not a blocker.
- No afterEach to normalize browserLoginAsHandler: each it() guards its own pre-condition inline; the four tests are correct as written. A shared afterEach would make future additions automatically isolated. Not a blocker.
Security
The removal of the controller-level env check from BrowserTestLogin::create in favor of BrowserTestFixtureGuard (raised in rounds 1-2) is unchanged. The trade-off is documented in the controller comment. Route-registration gate plus middleware guard are two independent layers; no dispatch path can reach BrowserTestLogin::create outside the /_browser scope without traversing the middleware.
Commits
- fix(test): escape hash in BrowserLoginAsHandlerSpec describe label — type fix, scope test, 54-char subject, body explains bundle-wide crash scope, DCO sign-off present. Clean.
- Merge commit — no commitlint requirement; conflict resolution message is informative.
Cross-engine
No new production code in these two commits. The string literal escape is engine-neutral. No cross-engine concerns.
Wheels Bot — Reviewer B (round 1)A's round-3 review (on b5fc2c5) is clean. The StructCopy dispute from B's prior round has been correctly absorbed: A no longer makes the consistency-argument claim (that SycophancyNone detected. A uses COMMENTED, not approve, and explicitly defers human sign-off for the security trade-off. False positivesNone detected.
Missed issuesNone detected on the two new commits (
The prior-round cross-engine analysis (BrowserTestFixtureGuard, routes.cfm handler selection) remains correct and unchanged. Verdict alignmentA's COMMENTED verdict is consistent with the findings: no blockers, all items are style notes or informational, and security-touching code explicitly requires human sign-off before merge. Correct call. ConvergenceAligned. B's prior dispute (false consistency claim in the StructCopy finding) has been resolved — A retracted it in round 2 and the retraction holds through round 3. A's remaining non-blocker items (StructCopy, afterEach, security middleware trade-off) are ones B also agrees are non-blocking. No remaining disputed claims. Joint recommendation: no changes required from the bot review cycle; the PR is ready for human sign-off on the security trade-off and merge when ready. |
Scope middleware registered by component-path string are instantiated by Dispatch.cfc::$resolveMiddlewareInstance() via CreateObject("component", name).init(). BrowserTestFixtureGuard defined no init(), so every /_browser/* request threw "has no function with name [init]", breaking the fixture pages exercised by BrowserLoginSpec and BrowserRouteSpec (HTTP 417 on the core suite).
Add a no-arg init() returning this, matching sibling middleware (SecurityHeaders/Cors/RateLimiter). Verified on a fresh local Lucee 7 server: wheelstest now 147/147 with zero 'has no init' server-log errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>
There was a problem hiding this comment.
Wheels Bot — Reviewer A
TL;DR: Round 4 — PR #2832 now at 58e3c7a55. The single new commit adds a no-arg init() to BrowserTestFixtureGuard, fixing a genuine crash bug: Dispatch.cfc:61 calls CreateObject("component", arguments.middleware).init() for every string-registered middleware, so the missing init() was throwing "has no function with name [init]" on every /_browser/* request and silently breaking BrowserLoginSpec and BrowserRouteSpec (HTTP 417). The fix is correct, minimal, and follows the established sibling pattern. Two minor carry-forward items remain but are not blockers. Verdict: comment.
Correctness
init() fix is correct and necessary (vendor/wheels/middleware/BrowserTestFixtureGuard.cfc, commit 58e3c7a55)
Dispatch.cfc:61 resolves string-registered middleware via:
return CreateObject("component", arguments.middleware).init();Without an init() method, every /_browser/* request threw "has no function with name [init]", breaking all fixture pages. The commit adds:
public BrowserTestFixtureGuard function init() {
return this;
}This matches the pattern in all three sibling middleware:
SecurityHeaders.cfc:24—public SecurityHeaders function init(...) { ... return this; }Cors.cfc:19—public Cors function init(...) { ... return this; }RateLimiter.cfc:44—public RateLimiter function init(...) { ... return this; }
Fix is minimal, correct, and engine-neutral.
Tests (carry-forward, non-blocking)
Two minor items from rounds 1–3 remain unaddressed in vendor/wheels/tests/specs/wheelstest/BrowserLoginAsHandlerSpec.cfc:
-
No
afterEachto normalizebrowserLoginAsHandler: Eachit()guards its own pre-condition inline. A sharedafterEachwould make isolation automatic for future additions; the four current tests are correct as written. -
StructCopyvsDuplicatefor_originalStaticRoutes(line 36): Matches the pattern inmapperSpec.cfcandmapperModernSpec.cfcand is safe because$lockedLoadRoutes()replacesapplication.wheels.routeswholesale. Not a correctness issue.
Security (carry-forward, resolved)
The controller-level env check removed from BrowserTestLogin::create is replaced by BrowserTestFixtureGuard. Now confirmed working end-to-end: Dispatch.cfc:61 instantiates the middleware via CreateObject().init() on every route in the /_browser scope. The init() fix makes the two-layer defense ($lockedLoadRoutes gate + middleware) actually functional in all dispatch paths.
Commits
Four branch commits:
feat(testing): add browserLoginAsHandler override for /_browser/login-as fixture— valid, 67-char subject, DCO sign-off. Clean.docs(web/guides): document browserLoginAsHandler override for loginAs fixture— valid, DCO sign-off. Clean.fix(test): escape '#' in BrowserLoginAsHandlerSpec describe label— valid, 54-char subject, DCO sign-off (Peter Amiri). Clean.fix(middleware): add init() to BrowserTestFixtureGuard— valid typefix, scopemiddleware, 43-char subject, body explains root cause and verified count 147/147, DCO sign-off (Peter Amiri). Clean.
Wheels Bot — Reviewer B (round 1)A's round-4 review on SycophancyNone detected. A uses COMMENTED rather than approve and explicitly defers human sign-off on the security trade-off. False positivesNone detected.
Missed issuesNone detected.
Verdict alignmentA's COMMENTED verdict is consistent with the findings: no blockers, the new commit is a single-method correctness fix, and the broader security trade-off still requires human review before merge. Correct call. ConvergenceAligned. The dispute from B's round-1 on |
Summary
Introduces
application.wheels.browserLoginAsHandler— a"Controller##action"string the framework reads at route-registration time and uses as the/_browser/login-astarget in place of the built-inBrowserTestLogin##create. Apps with a richer real-world session shape (e.g.session.member = { id, email, firstName, lastName }) can now drive the fixture without forking the vendor tree or duplicating the route + env-gate boilerplate. Env-gating moves from the framework controller into a newwheels.middleware.BrowserTestFixtureGuardattached to the whole/_browserscope so the gate still applies under override.Fixes #2830
Recommended path from research: #2830 (comment) — matches Wheels' existing
to = "Controller##action"routing convention, stores only a string inapplication.wheels(no Adobe CF function-member crash, anti-pattern #2), and avoids introducing event/signal infrastructure Wheels doesn't currently have. The Laravel/Django/Rails event-listener variant from the cross-framework survey is deferred (see research's open question on shipping it alongside).Related Issue
Closes #2830
Type of Change
Feature Completeness Checklist
Signed-off-by:(usegit commit -s); see CONTRIBUTING.mdvendor/wheels/tests/specs/wheelstest/BrowserLoginAsHandlerSpec.cfcpins the default →BrowserTestLogin##create, the override → app'sController##action, an empty-string fallback to the default, and theBrowserTestFixtureGuardmiddleware on the/_browserscope. Failing-then-passing TDD pair.bot-update-docs.ymlhandles MDX follow-upbot-update-docs.ymlhandles.ai/wheels/follow-upbot-update-docs.ymlhandles CLAUDE.md follow-up[Unreleased]→### Addedbash tools/test-local.sh wheels.tests.specs.wheelstestcould not be invoked in the bot's sandbox (thewheelsCLI binary isn't on PATH there); the bot-tdd-gate and compat-matrix CI runs on this PR are the canonical verification. The diff contains both spec and implementation changes — the TDD gate's spec/impl-diff requirement is satisfied.Test Plan
In an app with
set(loadBrowserTestFixtures = true):set(browserLoginAsHandler = "AuthFixture##loginAs")toconfig/settings.cfmand createapp/controllers/AuthFixture.cfcwith aloginAs()action that writes the app-specific session shape (session.member = {...}, etc.).this.browser.loginAs("alice@example.com")and assert the app's downstream pages (which inspectsession.member) render as a logged-in user.session.userId = 1,session.userEmail = identifier) should kick in again./_browser/login-asreturns a server error / refuses dispatch whenenvironmentis flipped toproductioneven withloadBrowserTestFixtures = true(the newBrowserTestFixtureGuardmiddleware throwsWheels.BrowserTestSecurityError).A human review is required before merge. Doc updates (MDX guides,
.ai/wheels/,CLAUDE.md) are handled separately bybot-update-docs.yml.