Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -85,46 +108,61 @@ const SEED_ENV_KEYS = [
] as const;
let savedEnv: Record<string, string | undefined> = {};

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<void> })?.destroy?.();
} catch {
/* noop */
}
}
});

async function bootEngine(): Promise<ObjectQL> {
/**
* [#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<ObjectQL> {
return engines.take();
}

/** The population that makes this card: people, no credentials. */
Expand Down
79 changes: 76 additions & 3 deletions packages/plugins/plugin-auth/src/implicit-account-linking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) ───────────────

Expand Down Expand Up @@ -122,8 +140,16 @@ const provider = (providerId: string) => ({
pkce: false,
});

const makeManager = (engine: any, config: Record<string, unknown> = {}) =>
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<AuthManager, any>();

const makeManager = (engine: any, config: Record<string, unknown> = {}) => {
const manager = new AuthManager({
secret: SECRET,
baseUrl: BASE,
dataEngine: engine,
Expand Down Expand Up @@ -157,6 +183,9 @@ const makeManager = (engine: any, config: Record<string, unknown> = {}) =>
},
...config,
} as any);
if (!('secondaryStorage' in config)) copyTargets.set(manager, engine);
return manager;
};

// ── The stubbed identity provider ────────────────────────────────────────────

Expand Down Expand Up @@ -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<string, any[]>; 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<string[]> => {
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);
Expand Down
63 changes: 58 additions & 5 deletions packages/plugins/plugin-auth/src/org-create-posture-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, any[]>; 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;
Expand All @@ -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);
Expand Down
Loading