diff --git a/packages/plugins/plugin-auth/src/dev-admin-seed-credential-gate.test.ts b/packages/plugins/plugin-auth/src/dev-admin-seed-credential-gate.test.ts index c7e60fd30e2..21467179644 100644 --- a/packages/plugins/plugin-auth/src/dev-admin-seed-credential-gate.test.ts +++ b/packages/plugins/plugin-auth/src/dev-admin-seed-credential-gate.test.ts @@ -49,7 +49,7 @@ * console reads the route and not the predicate. */ -import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; +import { describe, it, expect, afterAll, afterEach, beforeEach, vi } from 'vitest'; import { Hono } from 'hono'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; @@ -60,6 +60,29 @@ import { AuthPlugin } from './auth-plugin.js'; import { SELF_REGISTRATION_CLOSED } from './audience-posture.js'; import { decideDevAdminSeedGate } from './dev-admin-seed-gate.js'; import { recoverInternalFieldsForSystemRead } from './internal-field-readback.js'; +import { memoizeScrypt } from './__tests__/scrypt-memo.testkit'; +import { sqliteEnginePool } from './__tests__/sqlite-engine-pool.testkit'; +// [#23184] better-auth's first module load is paid HERE, at module load, and +// not inside the first clocked case: clocked windows measure behaviour, never +// loading (AGENTS.md, Build & Test). `AuthManager` reaches these vendor modules +// through a lazy `await import()`, so without these lines case ⓪ paid about a +// second of vendor module evaluation on a dev container. The module cache hands +// `AuthManager` these same instances. The one vendor value read at load is +// `NODE_ENV` (better-auth's env module keeps the value it sees when it is first +// evaluated), and that module is already evaluated before this point: the +// `./auth-manager.js` import above reaches `better-auth/adapters` through +// `./objectql-adapter.js`, and that import evaluates it. So these lines move no +// capture: the vendor reads the same `NODE_ENV` as before, whatever the +// `beforeEach` below sets, and nothing a case runs is different. +import 'better-auth'; +import 'better-auth/api'; +import 'better-auth/plugins'; +import 'better-auth/plugins/bearer'; +import 'better-auth/plugins/custom-session'; +import 'better-auth/plugins/organization'; +import 'better-auth/plugins/organization/access'; +import '@better-auth/oauth-provider'; +import '@better-auth/core/error'; const BASE = 'http://localhost:3000'; const AUTH_BASE = '/api/v1/auth'; @@ -85,46 +108,61 @@ const SEED_ENV_KEYS = [ ] as const; let savedEnv: Record = {}; -const engines: ObjectQL[] = []; - beforeEach(() => { savedEnv = Object.fromEntries(SEED_ENV_KEYS.map((k) => [k, process.env[k]])); for (const k of SEED_ENV_KEYS) delete process.env[k]; process.env.NODE_ENV = 'development'; }); -afterEach(async () => { +afterEach(() => { for (const [k, v] of Object.entries(savedEnv)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; } - while (engines.length) { - const e = engines.pop(); - try { - await (e as unknown as { destroy?(): Promise })?.destroy?.(); - } catch { - /* noop */ - } - } }); -async function bootEngine(): Promise { +/** + * [#23184] The engine every case starts from: booted ONCE per file and emptied + * before each case that takes it (`__tests__/sqlite-engine-pool.testkit.ts`), + * instead of booted per case. A boot here is `init()` plus the DDL for the 30 + * objects plugin-auth registers, about 70 ms on a dev container and the + * costliest step of the cases that derive no password. The engine a case + * takes was built by the same steps a fresh boot runs, and the pool hands it + * over with every table empty, so each case still starts from the empty + * database it always did. Nothing in this file adds a hook, an object or a + * table to an engine; the pool refuses an engine whose schema changed, and one + * left holding an organization row. + */ +const engines = sqliteEnginePool(async () => { const engine = new ObjectQL(); - engines.push(engine); - engine.registerDriver( - new SqlDriver({ - client: 'better-sqlite3', - connection: { filename: ':memory:' }, - useNullAsDefault: true, - }), - true, - ); + const driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + engine.registerDriver(driver, true); await engine.init(); for (const object of AUTH_OBJECTS) { engine.registry.registerObject(object as never, '@objectstack/plugin-auth'); } await engine.syncSchemas(); - return engine; + return { engine, driver }; +}); +afterEach(engines.release); +afterAll(engines.destroy); + +/** + * [#23184] A sign-in that follows the seed verifies the password against the + * hash the seed just stored, and a second seed run re-checks that same hash: + * the same password against the same salt, which the memo answers from the + * bytes the first derivation produced. Every fresh salt is still derived for + * real (`__tests__/scrypt-memo.testkit.ts`). + */ +const scrypt = memoizeScrypt(); +afterAll(scrypt.restore); + +async function bootEngine(): Promise { + return engines.take(); } /** The population that makes this card: people, no credentials. */ diff --git a/packages/plugins/plugin-auth/src/implicit-account-linking.test.ts b/packages/plugins/plugin-auth/src/implicit-account-linking.test.ts index 7bbd1777768..e405506b73d 100644 --- a/packages/plugins/plugin-auth/src/implicit-account-linking.test.ts +++ b/packages/plugins/plugin-auth/src/implicit-account-linking.test.ts @@ -24,6 +24,24 @@ import { unlinkTombstoneIdentifier, unlinkTombstoneUserPrefix, } from './implicit-account-linking'; +// [#23184] better-auth's first module load is paid HERE, at module load, and +// not inside the first clocked case: clocked windows measure behaviour, never +// loading (AGENTS.md, Build & Test). `AuthManager` reaches these vendor modules +// through a lazy `await import()`, so without these lines the first case that +// builds an instance paid about 0.7 s of vendor module evaluation on a dev +// container. The module cache hands `AuthManager` these same instances, and +// better-auth's one load-time read (`NODE_ENV`, kept by its env module when +// first evaluated) already happened at the `./auth-manager` import above, +// through `better-auth/adapters`, so nothing a case runs is different. +import 'better-auth'; +import 'better-auth/api'; +import 'better-auth/plugins'; +import 'better-auth/plugins/bearer'; +import 'better-auth/plugins/custom-session'; +import 'better-auth/plugins/generic-oauth'; +import 'better-auth/plugins/organization'; +import 'better-auth/plugins/organization/access'; +import '@better-auth/oauth-provider'; // ── In-memory IDataEngine (the audience-posture harness shape) ─────────────── @@ -122,8 +140,16 @@ const provider = (providerId: string) => ({ pkce: false, }); -const makeManager = (engine: any, config: Record = {}) => - new AuthManager({ +/** + * [#23184] The engine under each manager this file builds, for the managers a + * copied sign-up stands for (see {@link signUp}): every one but a manager with + * a `secondaryStorage`, where a sign-up writes its session to the cache rather + * than to the engine. + */ +const copyTargets = new WeakMap(); + +const makeManager = (engine: any, config: Record = {}) => { + const manager = new AuthManager({ secret: SECRET, baseUrl: BASE, dataEngine: engine, @@ -157,6 +183,9 @@ const makeManager = (engine: any, config: Record = {}) => }, ...config, } as any); + if (!('secondaryStorage' in config)) copyTargets.set(manager, engine); + return manager; +}; // ── The stubbed identity provider ──────────────────────────────────────────── @@ -221,8 +250,52 @@ const post = (manager: AuthManager, path: string, body: unknown, cookie?: string }), ); -/** Sign up with email + password; answers the session cookies. */ +/** + * [#23184] One sign-up, made ONCE per file through the real `/sign-up/email` + * route, on an engine and a manager of its own. + * + * A sign-up is the costly step of every end-to-end case here (one + * production-parameter scrypt derivation under a fresh salt, most of the + * case's wall), and no case is about it: each case is about what an external + * sign-in, a link or an unlink does to a local row that a sign-up made. So + * {@link signUp} gives a case's first sign-up a deep copy of the tables this + * one wrote, on the case's own engine, with the user row's address set to the + * case's own, and answers this sign-up's session cookies. + */ +const TEMPLATE_EMAIL = 'founder@example.test'; +let signedUp: Promise<{ tables: Map; cookies: string[] }> | undefined; +const signedUpOnce = () => + (signedUp ??= (async () => { + const template = createMemoryEngine(); + const res = await post(makeManager(template), 'sign-up/email', { + email: TEMPLATE_EMAIL, + password: PASSWORD, + name: 'Synthetic Person', + }); + expect(res.status).toBe(200); + return { tables: template.tables, cookies: cookiesFrom(res) }; + })()); + +/** + * Sign up with email + password; answers the session cookies. + * + * [#23184] The first sign-up on a manager's still-empty engine is the one + * above, copied (see {@link signedUpOnce}); a sign-up on an engine that already + * holds a user, or under a `secondaryStorage` manager, runs live as before. + * Measured for every address and manager configuration this file signs up + * with, the copy holds the same rows a live sign-up of that address writes + * (ids, tokens, the hash and timestamps aside), and its cookie opens a session + * for that user. The copy is `structuredClone`, so no case's write reaches the + * template or another case. + */ const signUp = async (manager: AuthManager, email: string): Promise => { + const engine = copyTargets.get(manager); + if (engine && (engine.tables.get('sys_user') ?? []).length === 0) { + const { tables, cookies } = await signedUpOnce(); + for (const [name, rows] of tables) engine.tables.set(name, structuredClone(rows)); + userRow(engine, TEMPLATE_EMAIL).email = email.toLowerCase(); + return cookies; + } const res = await post(manager, 'sign-up/email', { email, password: PASSWORD, name: 'Synthetic Person' }); expect(res.status).toBe(200); return cookiesFrom(res); diff --git a/packages/plugins/plugin-auth/src/org-create-posture-gate.test.ts b/packages/plugins/plugin-auth/src/org-create-posture-gate.test.ts index 75c9c54d886..2b44c1b6de4 100644 --- a/packages/plugins/plugin-auth/src/org-create-posture-gate.test.ts +++ b/packages/plugins/plugin-auth/src/org-create-posture-gate.test.ts @@ -54,10 +54,27 @@ // Real better-auth pipeline throughout (the #3585 EdDSA / #4785 session-of-record // precedent: patch the real thing, never stub our own code). -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest'; import { assertEngineDeleteDispatch, assertEngineFindOnePredicate } from '@objectstack/objectql'; import { AuthManager } from './auth-manager'; import { createTenancyService, type TenancyService, type TenancyPosture } from './tenancy-service'; +// [#23184] better-auth's first module load is paid HERE, at module load, and +// not inside the first clocked case: clocked windows measure behaviour, never +// loading (AGENTS.md, Build & Test). `AuthManager` reaches these vendor modules +// through a lazy `await import()`, so without these lines the first case paid +// about 0.8 s of vendor module evaluation on a dev container. The module cache +// hands `AuthManager` these same instances, and better-auth's one load-time +// read (`NODE_ENV`, kept by its env module when first evaluated) already +// happened at the `./auth-manager` import above, through +// `better-auth/adapters`, so nothing a case runs is different. +import 'better-auth'; +import 'better-auth/api'; +import 'better-auth/plugins'; +import 'better-auth/plugins/bearer'; +import 'better-auth/plugins/custom-session'; +import 'better-auth/plugins/organization'; +import 'better-auth/plugins/organization/access'; +import '@better-auth/oauth-provider'; /** * In-memory IDataEngine — same shape as the #4785 harness (`fields` really @@ -195,9 +212,44 @@ interface Scenario { } /** - * Boot a manager under a scenario and run the whole guided path: sign up, then - * ask for a workspace. Returns the real HTTP answer plus the `/auth/config` - * flag the console would have rendered its button from. + * [#23184] The founder's sign-up, made ONCE per file through the real + * `/sign-up/email` route, on an engine and a manager of its own. + * + * The sign-up is the costly step of a guided run (one production-parameter + * scrypt derivation under a fresh salt, most of each case's wall), and no case + * is about it: each case is about what `organization/create` and + * `/auth/config` answer for a signed-in founder under a scenario. So + * {@link runGuidedWorkspaceCreation} starts each case from a deep copy of the + * tables this sign-up wrote, on the case's own engine, with the case's own + * manager built under the case's own scenario, and presents the session cookie + * this sign-up answered. Both knobs are unset while it runs. Measured across + * every scenario in this file, a sign-up run under the scenario itself writes + * the same rows (ids, tokens, hashes and timestamps aside) and the case then + * answers the same status, body, flags and organization rows. The copy is + * `structuredClone`, so no case's write reaches the template or another case. + * The two cases that sign up inline below still do, under their own env. + */ +let signedUp: { tables: Map; cookie: string } | undefined; +beforeAll(async () => { + delete process.env.OS_TENANCY_POSTURE; + delete process.env.OS_MULTI_ORG_ENABLED; + try { + const template = createMemoryEngine(); + const cookie = cookieFrom(await signUp(makeManager(template), 'founder@example.com')); + signedUp = { tables: template.tables, cookie }; + } finally { + if (OLD_POSTURE === undefined) delete process.env.OS_TENANCY_POSTURE; + else process.env.OS_TENANCY_POSTURE = OLD_POSTURE; + if (OLD_LEGACY === undefined) delete process.env.OS_MULTI_ORG_ENABLED; + else process.env.OS_MULTI_ORG_ENABLED = OLD_LEGACY; + } +}); + +/** + * Boot a manager under a scenario and run the guided path from a signed-in + * founder (the sign-up above): ask for a workspace. Returns the real HTTP + * answer plus the `/auth/config` flag the console would have rendered its + * button from. */ const runGuidedWorkspaceCreation = async (scenario: Scenario, slug = 'acme') => { if (scenario.posture === undefined) delete process.env.OS_TENANCY_POSTURE; @@ -206,11 +258,12 @@ const runGuidedWorkspaceCreation = async (scenario: Scenario, slug = 'acme') => else process.env.OS_MULTI_ORG_ENABLED = scenario.legacy; const engine = createMemoryEngine(); + for (const [name, rows] of signedUp!.tables) engine.tables.set(name, structuredClone(rows)); const manager = makeManager( engine, scenario.tenancy ? { getTenancy: () => scenario.tenancy } : {}, ); - const cookie = cookieFrom(await signUp(manager, `founder-${slug}@example.com`)); + const cookie = signedUp!.cookie; expect(cookie).not.toBe(''); const response = await createOrganization(manager, cookie, slug);