diff --git a/packages/appkit/src/context/index.ts b/packages/appkit/src/context/index.ts index d306d359e..571df4a89 100644 --- a/packages/appkit/src/context/index.ts +++ b/packages/appkit/src/context/index.ts @@ -7,4 +7,3 @@ export { runInUserContext, } from "./execution-context"; export { ServiceContext } from "./service-context"; -export type { UserContext } from "./user-context"; diff --git a/packages/appkit/src/core/appkit.ts b/packages/appkit/src/core/appkit.ts index 24345c6e7..fd8aac616 100644 --- a/packages/appkit/src/core/appkit.ts +++ b/packages/appkit/src/core/appkit.ts @@ -10,14 +10,12 @@ import type { } from "shared"; import { version as productVersion } from "../../package.json"; import { CacheManager } from "../cache"; -import { runInUserContext, ServiceContext } from "../context"; -import type { UserContext } from "../context/user-context"; +import { ServiceContext } from "../context"; import { isInternalTelemetryEnabled, TelemetryReporter, } from "../internal-telemetry"; import { createLogger } from "../logging/logger"; -import { USER_CONTEXT_SYMBOL } from "../plugin/plugin"; import { ResourceRegistry, ResourceType } from "../registry"; import type { TelemetryConfig } from "../telemetry"; import { TelemetryManager } from "../telemetry"; @@ -134,32 +132,6 @@ export class AppKit { } } - /** - * Wraps all function properties in an exports object so they run - * inside the given user context (via AsyncLocalStorage). - * This ensures RoutingPool and other context-aware code sees the - * user identity even though the function was obtained outside the proxy. - */ - private wrapExportsInUserContext( - exports: Record, - userContext: UserContext, - ) { - for (const key in exports) { - if (!Object.hasOwn(exports, key)) continue; - const val = exports[key]; - if (typeof val === "function") { - const fn = val as (...args: unknown[]) => unknown; - exports[key] = (...args: unknown[]) => - runInUserContext(userContext, () => fn(...args)); - } else if (AppKit.isPlainObject(val)) { - this.wrapExportsInUserContext( - val as Record, - userContext, - ); - } - } - } - /** * Wraps a plugin's exports with an `asUser` method that returns * a user-scoped version of the exports. @@ -167,6 +139,11 @@ export class AppKit { * When `exports()` returns a callable (function), it is returned as-is * since the plugin manages its own `asUser` per-call (e.g. files plugin). * When it returns a plain object, the standard `asUser` wrapper is added. + * + * The OBO-side wrapping lives inside `Plugin.asUser` — calling + * `plugin.asUser(req).exports()` returns exports whose functions already + * run inside the user's AsyncLocalStorage scope. AppKit only adapts the + * shape; it does not own the user-context concept. */ private wrapWithAsUser(plugin: T) { // If plugin doesn't implement exports(), return empty object @@ -192,26 +169,8 @@ export class AppKit { * Returns user-scoped exports where all methods execute with the * user's Databricks credentials instead of the service principal. */ - asUser: (req: import("express").Request) => { - const userPlugin = (plugin as any).asUser(req); - const userContext = (userPlugin as any)[ - USER_CONTEXT_SYMBOL - ] as UserContext; - const userExports = (plugin.exports?.() ?? {}) as Record< - string, - unknown - >; - // Wrap each export in runInUserContext instead of bind. - // bind() bypasses the Proxy get trap, so methods called via bind - // would not run inside the user's AsyncLocalStorage context. - if (userContext) { - this.wrapExportsInUserContext(userExports, userContext); - } else { - // Fallback for dev mode proxy (no userContext symbol) - this.bindExportMethods(userExports, userPlugin); - } - return userExports; - }, + asUser: (req: import("express").Request) => + (plugin as any).asUser(req).exports() as Record, }; } diff --git a/packages/appkit/src/plugin/plugin.ts b/packages/appkit/src/plugin/plugin.ts index 4c00c41e1..8cb04a56d 100644 --- a/packages/appkit/src/plugin/plugin.ts +++ b/packages/appkit/src/plugin/plugin.ts @@ -14,12 +14,7 @@ import type { } from "shared"; import { AppManager } from "../app"; import { CacheManager } from "../cache"; -import { - getCurrentUserId, - runInUserContext, - ServiceContext, - type UserContext, -} from "../context"; +import { getCurrentUserId, runInUserContext, ServiceContext } from "../context"; import type { PluginContext } from "../core/plugin-context"; import { AppKitError, AuthenticationError } from "../errors"; import { createLogger } from "../logging/logger"; @@ -43,19 +38,46 @@ import type { const logger = createLogger("plugin"); -/** - * Symbol used to expose the UserContext from an asUser() proxy. - * Allows wrapWithAsUser in appkit.ts to retrieve the context and - * wrap export methods in runInUserContext(). - */ -export const USER_CONTEXT_SYMBOL = Symbol("appkit.userContext"); - /** * OTel context key for marking OBO dev mode fallback. * Set when asUser() is called in development mode without a user token. */ const DEV_OBO_FALLBACK_KEY = createContextKey("appkit.devOboFallback"); +/** + * Returns true if `value` is a plain object literal (not an array, Date, + * class instance, etc.). Used to decide whether to recurse into nested + * export shapes when wrapping functions. + */ +function isPlainObject(value: unknown): value is Record { + if (typeof value !== "object" || value === null) return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +/** + * Recursively replaces every function in `exports` with `wrap(fn)`, + * walking into nested plain objects. Mutates and returns `exports`. + * + * Used by the asUser proxy to make the user context follow function + * references that escape the proxy via `exports()`. + */ +function wrapExportFunctions( + exports: Record, + wrap: (fn: (...a: unknown[]) => unknown) => (...a: unknown[]) => unknown, +): Record { + for (const key of Object.keys(exports)) { + if (!Object.hasOwn(exports, key)) continue; + const val = exports[key]; + if (typeof val === "function") { + exports[key] = wrap(val as (...a: unknown[]) => unknown); + } else if (isPlainObject(val)) { + wrapExportFunctions(val as Record, wrap); + } + } + return exports; +} + /** * Returns true if the current execution is an OBO dev mode fallback * (asUser() was called but fell back to service principal due to missing token). @@ -403,30 +425,18 @@ export abstract class Plugin< const userEmail = req.header("x-forwarded-email"); const isDev = process.env.NODE_ENV === "development"; - // In local development, skip user impersonation - // since there's no user token available + // In local development, skip user impersonation since there's no user + // token available. Mark execution as OBO dev fallback via OTel context + // so telemetry can distinguish intended OBO calls from regular SP calls. if (!token && isDev) { logger.warn( "asUser() called without user token in development mode. Skipping user impersonation.", ); - // Return a proxy that marks execution as OBO dev fallback via OTel context, - // so telemetry spans can distinguish intended OBO calls from regular SP calls - return new Proxy(this, { - get: (target, prop, receiver) => { - const value = Reflect.get(target, prop, receiver); - if (typeof value !== "function") return value; - if (typeof prop === "string" && EXCLUDED_FROM_PROXY.has(prop)) - return value; - - return (...args: unknown[]) => { - const ctx = otelContext - .active() - .setValue(DEV_OBO_FALLBACK_KEY, true); - return otelContext.with(ctx, () => value.apply(target, args)); - }; - }, - }) as this; + return this._createAsUserProxy((fn) => (...args) => { + const ctx = otelContext.active().setValue(DEV_OBO_FALLBACK_KEY, true); + return otelContext.with(ctx, () => fn(...args)); + }); } if (!token) { @@ -446,34 +456,55 @@ export abstract class Plugin< userEmail ?? undefined, ); - // Return a proxy that wraps method calls in user context - return this._createUserContextProxy(userContext); + return this._createAsUserProxy( + (fn) => + (...args) => + runInUserContext(userContext, () => fn(...args)), + ); } /** - * Creates a proxy that wraps method calls in a user context. - * This allows all plugin methods to automatically use the user's - * Databricks credentials. + * Creates a proxy of `this` where every method call — and every function + * in the result of `exports()` — runs inside `wrapCall`. + * + * `wrapCall` decides the per-call scope. Two strategies are used today: + * - real OBO: fn => (...args) => runInUserContext(userContext, () => fn(...args)) + * - dev fallback: fn => (...args) => otelContext.with(DEV_OBO_FALLBACK_KEY=true, () => fn(...args)) + * + * `exports` is intercepted because methods captured in the returned + * exports object never re-enter the proxy's `get` trap. Wrapping them + * here is the only way to make the user context follow function + * references back out of the plugin. */ - private _createUserContextProxy(userContext: UserContext): this { + private _createAsUserProxy( + wrapCall: ( + fn: (...a: unknown[]) => unknown, + ) => (...a: unknown[]) => unknown, + ): this { return new Proxy(this, { get: (target, prop, receiver) => { - // Expose userContext via symbol so wrapWithAsUser can wrap exports - if (prop === USER_CONTEXT_SYMBOL) return userContext; - const value = Reflect.get(target, prop, receiver); - if (typeof value !== "function") { + if (typeof value !== "function") return value; + if (typeof prop === "string" && EXCLUDED_FROM_PROXY.has(prop)) return value; - } - if (typeof prop === "string" && EXCLUDED_FROM_PROXY.has(prop)) { - return value; + if (prop === "exports") { + return () => { + const raw = (value as () => unknown).call(target); + if (raw == null) return {}; + // Callable exports (e.g. files, jobs) manage per-call asUser + // themselves; leave them untouched. + if (typeof raw === "function") return raw; + if (isPlainObject(raw)) { + return wrapExportFunctions(raw, wrapCall); + } + return raw; + }; } - return (...args: unknown[]) => { - return runInUserContext(userContext, () => value.apply(target, args)); - }; + const fn = (value as (...a: unknown[]) => unknown).bind(target); + return wrapCall(fn); }, }) as this; } diff --git a/packages/appkit/src/plugin/tests/asUser-proxy.test.ts b/packages/appkit/src/plugin/tests/asUser-proxy.test.ts new file mode 100644 index 000000000..b5a1564bb --- /dev/null +++ b/packages/appkit/src/plugin/tests/asUser-proxy.test.ts @@ -0,0 +1,539 @@ +/** + * Tests for `Plugin.asUser(req)` — the proxy that wraps method calls and + * `exports()` results in the user's AsyncLocalStorage scope. + * + * `plugin.test.ts` already covers the dev-fallback proxy at the method-call + * level. This file exists to cover: + * 1. Real OBO method calls (token + userId headers) + * 2. `exports()` interception in both OBO and dev-fallback modes + * 3. Edge cases: nested objects, class instances, callable exports, + * excluded lifecycle methods, async propagation, error cleanup + * + * These tests probe the proxy directly, not through the AppKit layer. + * The AppKit-integration tests live in `core/tests/appkit-as-user-exports.test.ts`. + */ + +import { + type ContextManager, + context as otelContext, +} from "@opentelemetry/api"; +import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks"; +import { createMockTelemetry, mockServiceContext } from "@tools/test-helpers"; +import type express from "express"; +import type { BasePluginConfig } from "shared"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + test, + vi, +} from "vitest"; +import { AppManager } from "../../app"; +import { CacheManager } from "../../cache"; +import { getUserContext } from "../../context/execution-context"; +import { ServiceContext } from "../../context/service-context"; +import { StreamManager } from "../../stream"; +import type { ITelemetry, TelemetryProvider } from "../../telemetry"; +import { TelemetryManager } from "../../telemetry"; +import { isDevOboFallback, Plugin } from "../plugin"; + +vi.mock("@databricks/sdk-experimental", () => ({ + ApiError: class extends Error { + statusCode = 500; + }, +})); + +vi.mock("../../app"); +vi.mock("../../cache", () => ({ + CacheManager: { getInstanceSync: vi.fn() }, +})); +vi.mock("../../stream"); +vi.mock("../../telemetry", () => ({ + TelemetryManager: { getProvider: vi.fn() }, + normalizeTelemetryOptions: vi.fn(() => ({ traces: false })), +})); + +// ── Test plugins ──────────────────────────────────────────────────── + +/** + * Captures `getUserContext()` at call time and exposes it for assertions. + * Covers every shape a plugin can export: class method, arrow function, + * nested object, class instance, primitives, and callable-style exports. + */ +class ProbePlugin extends Plugin { + /** Captures the user-id observed on the last call, for nested-call tests. */ + observedFromInner: string | undefined; + + async observeAsync(): Promise { + return getUserContext()?.userId; + } + + observeSync(): string | undefined { + return getUserContext()?.userId; + } + + // Calls observeSync via `this` — we use this to prove the inner call + // inherits the user context from the outer wrapped call. + outerCallsInner(): string | undefined { + this.observedFromInner = this.observeSync(); + return this.observedFromInner; + } + + syncThrows(): never { + throw new Error("sync boom"); + } + + async asyncRejects(): Promise { + throw new Error("async boom"); + } + + exports() { + return { + classMethod: this.observeSync.bind(this), + arrowFn: () => getUserContext()?.userId, + asyncArrowFn: async () => { + // Force an await so we exercise AsyncLocalStorage propagation. + await Promise.resolve(); + return getUserContext()?.userId; + }, + nested: { + innerArrow: () => getUserContext()?.userId, + deeper: { + deepest: () => getUserContext()?.userId, + }, + }, + // Class instance — must NOT be recursed into by wrapExportFunctions. + classInstance: new Date("2026-01-01"), + // Array — must NOT be recursed into either. + arrayValue: [() => getUserContext()?.userId], + // Primitives — must be preserved exactly. + count: 42, + name: "probe", + nullish: null, + }; + } +} + +/** Exports as a function (the files/jobs pattern) — never wrapped. */ +class CallablePlugin extends Plugin { + exports() { + return (key: string) => `handle:${key}`; + } +} + +/** Exports returns `undefined` — must be treated as `{}`. */ +class NullExportsPlugin extends Plugin { + exports(): undefined { + return undefined; + } +} + +// ── Test plumbing ─────────────────────────────────────────────────── + +function createReqWithObo(): express.Request { + return { + header: (name: string) => { + const map: Record = { + "x-forwarded-access-token": "user-token-abc", + "x-forwarded-user": "alice", + "x-forwarded-email": "alice@example.com", + }; + return map[name.toLowerCase()]; + }, + } as unknown as express.Request; +} + +function createReqWithoutToken(): express.Request { + return { + header: () => undefined, + } as unknown as express.Request; +} + +describe("Plugin.asUser proxy", () => { + let mockTelemetry: ITelemetry; + let mockCache: CacheManager; + let serviceContextMock: Awaited>; + let config: BasePluginConfig; + let contextManager: ContextManager; + + beforeAll(() => { + otelContext.disable(); + contextManager = new AsyncLocalStorageContextManager().enable(); + otelContext.setGlobalContextManager(contextManager); + }); + + afterAll(() => { + otelContext.disable(); + }); + + beforeEach(async () => { + ServiceContext.reset(); + serviceContextMock = await mockServiceContext(); + + mockTelemetry = createMockTelemetry(); + mockCache = { + get: vi.fn(), + set: vi.fn(), + delete: vi.fn(), + } as unknown as CacheManager; + + vi.mocked(CacheManager.getInstanceSync).mockReturnValue(mockCache); + vi.mocked(AppManager).mockImplementation( + () => ({ getAppQuery: vi.fn() }) as unknown as AppManager, + ); + vi.mocked(StreamManager).mockImplementation( + () => + ({ stream: vi.fn(), abortAll: vi.fn() }) as unknown as StreamManager, + ); + vi.mocked(TelemetryManager.getProvider).mockReturnValue( + mockTelemetry as TelemetryProvider, + ); + + config = { name: "probe" }; + }); + + afterEach(() => { + serviceContextMock?.restore(); + vi.clearAllMocks(); + }); + + // ── Real OBO: method-call proxy ──────────────────────────────────── + + describe("real OBO — method calls", () => { + test("async method runs inside the user's AsyncLocalStorage scope", async () => { + const plugin = new ProbePlugin(config); + const proxied = plugin.asUser(createReqWithObo()); + + await expect(proxied.observeAsync()).resolves.toBe("alice"); + }); + + test("sync method runs inside the user's AsyncLocalStorage scope", () => { + const plugin = new ProbePlugin(config); + const proxied = plugin.asUser(createReqWithObo()); + + expect(proxied.observeSync()).toBe("alice"); + }); + + test("getUserContext() returns undefined outside the proxy", () => { + const plugin = new ProbePlugin(config); + // Sanity: SP-side calls have no user context. + expect(plugin.observeSync()).toBeUndefined(); + }); + + test("non-function properties pass through unchanged", () => { + const plugin = new ProbePlugin(config); + const proxied = plugin.asUser(createReqWithObo()); + + expect(proxied.name).toBe(plugin.name); + }); + + test("excluded lifecycle methods (setup) are not wrapped in user context", async () => { + class WatchSetup extends ProbePlugin { + observedDuringSetup: string | undefined; + async setup() { + this.observedDuringSetup = getUserContext()?.userId; + } + } + + const plugin = new WatchSetup(config); + const proxied = plugin.asUser(createReqWithObo()); + + await proxied.setup(); + + // setup is in EXCLUDED_FROM_PROXY, so it runs as the SP. + expect(plugin.observedDuringSetup).toBeUndefined(); + }); + + test("inner this.method() call inherits the outer user context", () => { + const plugin = new ProbePlugin(config); + const proxied = plugin.asUser(createReqWithObo()); + + expect(proxied.outerCallsInner()).toBe("alice"); + }); + + test("sync throw propagates and clears context after the call", () => { + const plugin = new ProbePlugin(config); + const proxied = plugin.asUser(createReqWithObo()); + + expect(() => proxied.syncThrows()).toThrow("sync boom"); + // After the call returns, we should be back to no user context. + expect(getUserContext()).toBeUndefined(); + }); + + test("async reject propagates and clears context after the call", async () => { + const plugin = new ProbePlugin(config); + const proxied = plugin.asUser(createReqWithObo()); + + await expect(proxied.asyncRejects()).rejects.toThrow("async boom"); + expect(getUserContext()).toBeUndefined(); + }); + }); + + // ── Real OBO: exports() interception ─────────────────────────────── + + describe("real OBO — exports() interception", () => { + test("class method export sees user context", () => { + const plugin = new ProbePlugin(config); + const exports = plugin.asUser(createReqWithObo()).exports(); + + expect((exports as any).classMethod()).toBe("alice"); + }); + + test("inline arrow function export sees user context", () => { + const plugin = new ProbePlugin(config); + const exports = plugin.asUser(createReqWithObo()).exports(); + + expect((exports as any).arrowFn()).toBe("alice"); + }); + + test("async exported function preserves context across await", async () => { + const plugin = new ProbePlugin(config); + const exports = plugin.asUser(createReqWithObo()).exports(); + + await expect((exports as any).asyncArrowFn()).resolves.toBe("alice"); + }); + + test("nested plain objects are recursed into", () => { + const plugin = new ProbePlugin(config); + const exports = plugin.asUser(createReqWithObo()).exports() as any; + + expect(exports.nested.innerArrow()).toBe("alice"); + expect(exports.nested.deeper.deepest()).toBe("alice"); + }); + + test("class instance values are not recursed into", () => { + const plugin = new ProbePlugin(config); + const exports = plugin.asUser(createReqWithObo()).exports() as any; + + // Date is a class instance; isPlainObject() rejects it, so it's + // returned identity-equal to the original. + expect(exports.classInstance).toBeInstanceOf(Date); + expect(exports.classInstance.toISOString()).toBe( + "2026-01-01T00:00:00.000Z", + ); + }); + + test("array values are not recursed into", () => { + const plugin = new ProbePlugin(config); + const exports = plugin.asUser(createReqWithObo()).exports() as any; + + // The array's contained function is *not* wrapped — arrays aren't + // plain objects. Calling it directly returns undefined. + expect(Array.isArray(exports.arrayValue)).toBe(true); + expect(exports.arrayValue[0]()).toBeUndefined(); + }); + + test("non-function primitives are preserved", () => { + const plugin = new ProbePlugin(config); + const exports = plugin.asUser(createReqWithObo()).exports() as any; + + expect(exports.count).toBe(42); + expect(exports.name).toBe("probe"); + expect(exports.nullish).toBeNull(); + }); + + test("calling exports() twice returns independent objects", () => { + const plugin = new ProbePlugin(config); + const proxied = plugin.asUser(createReqWithObo()); + + const first = proxied.exports() as any; + const second = proxied.exports() as any; + + expect(first).not.toBe(second); + // Both work independently. + expect(first.arrowFn()).toBe("alice"); + expect(second.arrowFn()).toBe("alice"); + }); + + test("callable exports (function return) are returned as-is", () => { + const plugin = new CallablePlugin(config); + const result = plugin.asUser(createReqWithObo()).exports(); + + // Same function identity as the plugin's own exports() return value. + expect(typeof result).toBe("function"); + expect((result as (k: string) => string)("vol")).toBe("handle:vol"); + }); + + test("exports() returning undefined yields an empty object", () => { + const plugin = new NullExportsPlugin(config); + const result = plugin.asUser(createReqWithObo()).exports(); + + expect(result).toEqual({}); + }); + + test("default exports() (empty object) yields an empty object", () => { + class Bare extends Plugin {} + const plugin = new Bare(config); + const result = plugin.asUser(createReqWithObo()).exports(); + + expect(result).toEqual({}); + }); + }); + + // ── Real OBO: AsyncLocalStorage propagation ──────────────────────── + + describe("real OBO — async propagation", () => { + test("user context survives Promise.all branches", async () => { + class Parallel extends Plugin { + async fanOut(): Promise<(string | undefined)[]> { + // Each branch awaits before reading the context, forcing + // AsyncLocalStorage to bridge multiple microtask hops. + return Promise.all([ + Promise.resolve().then(() => getUserContext()?.userId), + Promise.resolve().then(async () => { + await Promise.resolve(); + return getUserContext()?.userId; + }), + Promise.resolve().then(() => getUserContext()?.userId), + ]); + } + } + const plugin = new Parallel(config); + const proxied = plugin.asUser(createReqWithObo()); + + await expect(proxied.fanOut()).resolves.toEqual([ + "alice", + "alice", + "alice", + ]); + }); + + test("two concurrent proxies do not see each other's user context", async () => { + const reqAlice = createReqWithObo(); + const reqBob: express.Request = { + header: (name: string) => + ({ + "x-forwarded-access-token": "user-token-bob", + "x-forwarded-user": "bob", + "x-forwarded-email": "bob@example.com", + })[name.toLowerCase()], + } as unknown as express.Request; + + const plugin = new ProbePlugin(config); + + const [alice, bob] = await Promise.all([ + plugin.asUser(reqAlice).observeAsync(), + plugin.asUser(reqBob).observeAsync(), + ]); + + expect(alice).toBe("alice"); + expect(bob).toBe("bob"); + }); + }); + + // ── Boundaries: function references that escape the proxy ───────── + + describe("real OBO — function references that escape the proxy", () => { + test("a function returned by a method is not auto-wrapped", () => { + class Factory extends Plugin { + // The returned arrow is *created* inside the user context, but + // it's the act of *invoking* it that needs to be in scope. + // Returning a function out of the proxy hands it back to a caller + // who is outside any user-context scope, so calling it later sees + // no context. This documents the proxy's wrapping boundary. + makeReader(): () => string | undefined { + return () => getUserContext()?.userId; + } + } + + const plugin = new Factory(config); + const proxied = plugin.asUser(createReqWithObo()); + + const reader = proxied.makeReader(); + expect(reader()).toBeUndefined(); + }); + + test("a method that returns `this` returns the unwrapped target", () => { + class Fluent extends Plugin { + chain(): this { + return this; + } + } + + const plugin = new Fluent(config); + const proxied = plugin.asUser(createReqWithObo()); + + const result = proxied.chain(); + + // The wrapper binds to target before calling, so `this` inside + // chain() is the raw plugin. Fluent APIs that `return this` break + // out of the proxy — subsequent calls on the result are SP-scoped. + expect(result).toBe(plugin); + expect(result).not.toBe(proxied); + }); + + test("an error thrown by exports() propagates from proxied.exports()", () => { + class BadExports extends Plugin { + exports() { + throw new Error("exports failed"); + } + } + + const plugin = new BadExports(config); + const proxied = plugin.asUser(createReqWithObo()); + + expect(() => proxied.exports()).toThrow("exports failed"); + // Nothing should have leaked into the surrounding scope. + expect(getUserContext()).toBeUndefined(); + }); + }); + + // ── Dev fallback exports() interception ──────────────────────────── + + describe("dev fallback — exports() interception", () => { + let originalNodeEnv: string | undefined; + + beforeEach(() => { + originalNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = "development"; + }); + + afterEach(() => { + process.env.NODE_ENV = originalNodeEnv; + }); + + test("exported arrow function runs with isDevOboFallback() === true", () => { + class DevProbe extends Plugin { + captured: boolean | undefined; + exports() { + return { + tag: () => { + this.captured = isDevOboFallback(); + }, + }; + } + } + + const plugin = new DevProbe(config); + const exports = plugin.asUser(createReqWithoutToken()).exports() as { + tag: () => void; + }; + + exports.tag(); + + expect(plugin.captured).toBe(true); + // The flag should not leak outside the call. + expect(isDevOboFallback()).toBe(false); + }); + + test("exports() preserves non-function values in dev fallback", () => { + class DevProbe extends Plugin { + exports() { + return { meta: { version: 1 }, label: "hello" }; + } + } + + const plugin = new DevProbe(config); + const exports = plugin.asUser(createReqWithoutToken()).exports() as { + meta: { version: number }; + label: string; + }; + + expect(exports.meta).toEqual({ version: 1 }); + expect(exports.label).toBe("hello"); + }); + }); +});