Skip to content

Browser-test fixture: built-in /_browser/login-as sets bare session.userId / session.userEmail, no extension point for app session shape #2830

Closed

Description

@bpamiri

Summary

The built-in /_browser/login-as fixture mounted when application.wheels.loadBrowserTestFixtures = true calls into vendor/wheels/public/browser-fixtures/controllers/BrowserTestLogin.cfc#create(), whose entire body sets:

session.userId = 1;
session.userEmail = params.identifier;

This shape is fine for the framework's own demo specs (see vendor/wheels/tests/specs/wheelstest/BrowserLoginSpec.cfc) but is incompatible with any real app whose authentication writes a richer session structure — e.g. session.member = { id, email, firstName, lastName }, plus app-specific session enrichment.

There's no extension point for an app that opts into loadBrowserTestFixtures = true to also have its app-specific session keys populated by the fixture. The only paths today:

  • Don't opt in to the framework fixture and write your own controller + route at the same /_browser/login-as path before the framework include. This is what we did. Works, but means every Wheels app that wants browser-test auth duplicates the route + controller + environment-gating boilerplate. It also can't easily co-exist with the framework fixture if a plugin later wants to use the built-in path.
  • Patch the framework's BrowserTestLogin.cfc to also set app-specific session keys. Brittle (vendor-tree changes get clobbered on upgrade) and doesn't compose if multiple plugins want a say.

Why it matters

BrowserClient.loginAs(identifier) (in vendor/wheels/wheelstest/BrowserClient.cfc:547-550) is the documented DSL entrypoint for browser-test auth, but it points at a fixture whose session shape doesn't match real apps. The result is: most production-app teams writing browser specs will eventually need to abandon loadBrowserTestFixtures=true and re-implement the same login fixture themselves.

Suggestion

A configurable callback or override hook. Two shapes that would work:

  1. Settings-driven handler. set(browserLoginAsHandler = "AuthFixture##loginAs") in config/settings.cfm tells the framework to delegate /_browser/login-as to the app's controller instead of running BrowserTestLogin#create. The framework route still mounts and still env-gates, but body execution is the app's. Handler receives (identifier) (plus any additional query params the BrowserClient DSL adds, e.g. our &isAD=false).

  2. Lifecycle hook. A $afterBrowserLogin(identifier) callback the framework calls after setting its own session.userId/session.userEmail. The app fills in its richer session state on top. Less invasive but only works if your app's session shape extends the fixture's; doesn't help apps that need different/no top-level userId.

(1) is more flexible and matches existing Wheels convention-with-override patterns. Either way, docs at https://guides.wheels.dev/v4-0-0/testing/browser-testing/ should call out that the built-in fixture's session shape is {userId, userEmail} so app authors know upfront whether the fixture is usable for them.

Discovered during

Phase 1 of an internal RBAC portal build on Wheels 4.0.2 / Lucee 7. We ended up shipping a custom BrowserLoginAs.cfc controller mounted at /_browser/login-as (registered before the namespace block in config/routes.cfm) that sets session.member, session.isADAuth, and calls our app's session-enrichment helper. The custom controller env-gates identically to the framework's. Pattern is in https://github.com/paiindustries/titan/pull/3337 (private). Happy to extract specific snippets if useful for spec design.

Activity

  1. wheels-bot commented on May 28, 2026

    @wheels-bot
    Contributor

    Wheels Bot — Triage

    Classified as framework-design (API/feature design question).
    Cross-framework research will follow shortly — Rails, Laravel, Django,
    and other modern frameworks will be consulted before any path forward
    is proposed.

    <!-- wheels-bot:triage:2830 -->
    <!-- wheels-bot:triage-class:framework-design -->

  2. wheels-bot commented on May 28, 2026

    @wheels-bot
    Contributor

    Wheels Bot — Cross-Framework Research

    Question

    The built-in /_browser/login-as fixture (BrowserTestLogin#create) hard-codes session.userId = 1 and session.userEmail = params.identifier. Real apps store richer session shapes (e.g. session.member = { id, email, firstName, lastName }). There's no extension point for apps that opt into loadBrowserTestFixtures = true to customize the fixture's session writes — today's only paths (don't opt in + write a parallel route, or patch the vendor tree) duplicate boilerplate or break on upgrade. How should the framework expose a clean override hook?

    How other frameworks handle this

    Framework Naming Surface Trade-offs Edge cases
    Rails (Devise/Warden) sign_in(user) / login_as In-process Warden helper; model hook serialize_into_session(record) controls session shape Same call works in integration + system tests; out-of-process drivers need real HTTP MFA bypassed; multi-scope namespacing; 4 KB cookie store cap
    Laravel (Dusk) loginAs($userId, $guard?) browser; actingAs($user) feature Built-in _dusk/login/{userId}/{guard?} HTTP route; SessionGuard::login() fires Illuminate\Auth\Events\Login, app subscribes via listener One-liner; no callback on the route itself, enrichment rides the Login event Multiple guards; email-as-id; MFA out of scope
    Django Client.force_login(user, backend=None) Writes three _auth_user_* session keys; fires user_logged_in signal; cookie-transplant for Selenium Fast (bypasses password); signal is the canonical extension seam Multi-backend explicit; cookie pattern undocumented but widespread
    Phoenix log_in_user(conn, user) Scaffolded into the app by mix phx.gen.auth (both MyAppAuth.log_in_user/3 and MyAppConnCase.log_in_user/2); framework ships no built-in endpoint Maximum flexibility (app owns the function body); no shared contract; every app diverges Token-based variant; sudo-mode opts; LiveView/Wallaby reuse the prepared conn
    Symfony (+1: closest PHP analogue, server-side session) KernelBrowser::loginUser($user, $firewall='main', $tokenAttributes=[]) Direct session write of a serialized TestBrowserToken under _security_<firewall>; $tokenAttributes accepts ad-hoc enrichment One line; does NOT fire LoginSuccessEvent so app listeners are bypassed in tests — widely-cited pain Stateless firewalls need per-request injection; Panther needs cookie copy

    Dominant pattern

    All five frameworks converge on the same shape: framework writes a minimal default, app extends. They diverge sharply on the extension mechanism. Three of five (Rails, Django, Laravel) use event/signal listeners that fire from both real and test logins — clean composability with plugins, but it requires pub/sub infrastructure. Symfony does direct token-attribute injection per-call but bypasses its own event chain in tests (a known wart). Phoenix punts entirely with scaffold-the-helper-into-the-app, taking "let the app own the body" to its extreme. The user's suggestion (1) — settings-driven handler delegation — is the Phoenix endpoint dressed in Wheels' Controller##action convention; suggestion (2) is the Laravel/Django/Rails event approach.

    Recommended path for Wheels

    Ship the user's suggestion (1) as the canonical extension point. It matches Wheels' existing routing convention (to = "Controller##action" strings), stores nothing but a string in application.wheels (no Adobe-CF function-member crash, anti-pattern #2), reuses existing route dispatch and controller invocation, and the app's handler is just a normal controller with full access to params, session, model(), and inject(). The framework keeps env-gating and view rendering; the app provides the body. Suggestion (2)'s lifecycle hook is the cross-framework majority but requires event infrastructure Wheels doesn't currently have — defer it.

    // vendor/wheels/public/browser-fixtures/routes.cfm — read override at route-registration
    <cfscript>
    local.loginAsTarget = StructKeyExists(application.wheels, "browserLoginAsHandler")
        && Len(application.wheels.browserLoginAsHandler)
        ? application.wheels.browserLoginAsHandler
        : "BrowserTestLogin##create";
    
    mapper()
        .scope(path = "/_browser", middleware = ["wheels.middleware.BrowserTestFixtureGuard"])
            .get(name = "browserTestLoginAs", pattern = "/login-as", to = local.loginAsTarget)
            // ... other fixture routes ...
        .end()
    .end();
    </cfscript>
    
    // vendor/wheels/middleware/BrowserTestFixtureGuard.cfc — env-gate moves to middleware
    // so it still runs when the app's handler is mapped.
    component implements="wheels.middleware.MiddlewareInterface" {
        public any function handle(required struct request, required any next) {
            if (!ListFindNoCase("testing,development", application.wheels.environment)) {
                Throw(
                    type = "Wheels.BrowserTestSecurityError",
                    message = "/_browser/* endpoints are only available in testing/development"
                );
            }
            return arguments.next(arguments.request);
        }
    }
    
    // App usage — config/settings.cfm
    set(loadBrowserTestFixtures = true);
    set(browserLoginAsHandler = "AuthFixture##loginAs");
    
    // app/controllers/AuthFixture.cfc
    component extends="Controller" {
        function loginAs() {
            var member = model("Member").findOne(where = "email = '#params.identifier#'");
            if (IsObject(member)) {
                session.member = {
                    id: member.id,
                    email: member.email,
                    firstName: member.firstName,
                    lastName: member.lastName
                };
                session.isADAuth = (params.isAD ?: false);
            }
            renderText("ok");
        }
    }

    Lives in: vendor/wheels/public/browser-fixtures/routes.cfm (override read at route registration) + new vendor/wheels/middleware/BrowserTestFixtureGuard.cfc (env-gate extracted from the controller so it still applies under override).

    Composes with: existing Controller##action route targets, the DI container (inject() in config()), params / session, model finders, and any other controller filter. The app's handler is a normal Wheels controller.

    Cross-engine notes

    Clean. Setting holds a string ("AuthFixture##loginAs"), never a closure — avoids the Adobe CF application-scope function-member crash (CLAUDE.md anti-pattern #2). Override is read at route-registration time (single-threaded boot), so no runtime closure-capture concerns. No obj.map(), no obj["key"]() in closures, no <cfheader attributeCollection=arguments> patterns. Standard middleware + controller dispatch, both already validated across Lucee 5/6/7, Adobe 2018/2021/2023/2025, and BoxLang.

    Open questions

    • Should the framework also ship a lighter after-login enrichment callback (set(afterBrowserLoginAs = "AuthFixture##enrichSession")) that runs on top of framework defaults, for apps that want to add fields rather than replace the body? This is the Laravel/Django event-listener shape and composes better with plugins, but adds a second extension point.
    • Should the env-gate middleware apply to the whole /_browser/* scope (defense-in-depth, recommended) or only login-as (matches today's behavior)?
    • Setting name — browserLoginAsHandler (matches the URL segment) vs browserTestLoginAs (matches loadBrowserTestFixtures) vs browserLoginAs? Prefer the first for clarity.
    • Should the app's handler live in app/controllers/ (current convention) or a dedicated app/test-fixtures/controllers/ directory so it's visually quarantined? The loadBrowserTestFixtures gate prevents route registration in production, but the controller class file still ships.
    • Should the override also accept a DI service name (set(browserLoginAsHandler = "browserAuthService")) resolved via service(), for apps that want a non-controller object to handle this? Symmetric with Wheels' DI container conventions.

    Confidence: medium

    Three of five surveyed frameworks (Rails, Django, Laravel) use event/signal listeners as the canonical extension point — a pattern Wheels can't adopt without new pub/sub infrastructure. The handler-override approach (the user's preferred suggestion, well-precedented by Phoenix's scaffold model and Laravel's route-name-takeover escape hatch) is the cleanest fit for Wheels conventions, but it's a meaningful fork from the cross-framework majority — a human should weigh whether to also ship the event/callback variant for plugin composability.

    Sources

  3. wheels-bot commented on May 28, 2026

    @wheels-bot
    Contributor

    Wheels Bot — Fix proposed

    Draft PR: #2832

    Spec: vendor/wheels/tests/specs/wheelstest/BrowserLoginAsHandlerSpec.cfc — failing → passing.
    Implementation: vendor/wheels/public/browser-fixtures/routes.cfm (reads application.wheels.browserLoginAsHandler, defaults to BrowserTestLogin##create), new vendor/wheels/middleware/BrowserTestFixtureGuard.cfc (env-gate on the /_browser scope), and vendor/wheels/public/browser-fixtures/controllers/BrowserTestLogin.cfc (drops the redundant in-controller env-gate now that the middleware owns it).

    Followed the cross-framework research's recommended path: a settings-driven to = "Controller##action" handler override. The Laravel/Django/Rails event-listener variant from the survey is deferred per the research's note that it would require pub/sub infrastructure Wheels doesn't currently have.

    A human review is required before merge. Reviewer A and Reviewer B will weigh in shortly. Doc updates (MDX guides, .ai/wheels/, CLAUDE.md) are handled separately by bot-update-docs.yml.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions