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
55 changes: 49 additions & 6 deletions packages/plugins/plugin-auth/src/cloud-managed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
// SSO-only switched on there, so the narrowing is the managed mode's and not
// SSO-only's.

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest';
import { Hono } from 'hono';
import type { PluginContext } from '@objectstack/core';
import { resetCloudManagedForTesting } from '@objectstack/core';
Expand Down Expand Up @@ -116,10 +116,11 @@ const makeManager = (engine: ReturnType<typeof createMemoryEngine>, extra: Recor
} as any);

/**
* One deployment, staged UNMANAGED (so its fixture users can sign up through
* the invitation lane), then served through the real mount chain. The tests
* switch the mode on afterwards: every door under test reads the accessor per
* request.
* The staged rows every door case starts from, produced ONCE for the file
* (#23181): staging signs three users up, and each sign-up is a fresh-salt
* production scrypt derivation, the cost that made fourteen identical
* stagings the bulk of this file's wall. Staged UNMANAGED (so the fixture
* users can sign up through the invitation lane), with SSO-only off.
*
* - `admin`: a platform admin (the legacy `role` scalar, which
* `isPlatformAdminUser` accepts) and the owner of the default organization,
Expand All @@ -128,8 +129,11 @@ const makeManager = (engine: ReturnType<typeof createMemoryEngine>, extra: Recor
* - `member`: an ordinary `env_native` user holding a local password.
* - `idp`: an `idp_provisioned` user with no local credential, the
* population the cloud's identity provider owns.
*
* The bearers are the sessions better-auth minted at staging; they live as
* `sys_session` rows, so every copy of the tables carries them.
*/
async function stage() {
async function produceStagedRows() {
const engine = createMemoryEngine();
engine.tables.set('sys_organization', [{ id: DEFAULT_ORG, name: 'Default', slug: 'default' }]);
const manager = makeManager(engine);
Expand Down Expand Up @@ -187,6 +191,45 @@ async function stage() {
users().find((r) => String(r.id) === ids.idp)!.source = 'idp_provisioned';
users().find((r) => String(r.id) === ids.member)!.source = 'env_native';

return { tables: structuredClone(new Map(engine.tables)), ids, bearers };
}

let staged: Awaited<ReturnType<typeof produceStagedRows>>;

beforeAll(async () => {
const saved = { [MANAGED_ENV]: process.env[MANAGED_ENV], [SSO_ONLY_ENV]: process.env[SSO_ONLY_ENV] };
setManaged(false);
delete process.env[SSO_ONLY_ENV];
const quiet = (['info', 'warn', 'error'] as const).map((level) =>
vi.spyOn(console, level).mockImplementation(() => {}),
);
try {
staged = await produceStagedRows();
} finally {
for (const spy of quiet) spy.mockRestore();
for (const [key, value] of Object.entries(saved)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
resetCloudManagedForTesting();
}
}, 120_000);

/**
* One deployment over its OWN copy of the staged rows: a fresh engine, a fresh
* `AuthManager` and a fresh mount chain per case, exactly as a per-case
* staging gave, so no case sees another's writes. The tests switch the mode
* on afterwards: every door under test reads the accessor per request.
*/
async function stage() {
const engine = createMemoryEngine();
for (const [name, rows] of structuredClone(staged.tables)) engine.tables.set(name, rows);
const manager = makeManager(engine);
const { ids, bearers } = staged;

const users = () => (engine.tables.get('sys_user') ?? []) as any[];
const accounts = () => (engine.tables.get('sys_account') ?? []) as any[];

const app = new Hono();
const ctx = mockCtx();
const plugin = new AuthPlugin({ secret: SECRET });
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
// test-budget: 20 — spawns the real producer once per single-cause fixture: real-kernel boots over a SQLite file holding a 2,500-row history, each remaining full walk its case's subject (the shared first walk, a write raced against a held walk, readiness against a held page, a shutdown mid-walk, the ledger-less boots, the forced rebuild); slowest CI reading 15.2 s (scheduled full-battery run 41271604267, Test Core shard log) x 1.25, rounded up; read before the three cases sharing the first walk took a copy of it

/**
* #25634 — the boot reconcile reads what changed, not the whole table, and
Expand All @@ -17,8 +18,8 @@
* `find` for the probe object — the cost a boot actually pays.
*/

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { copyFileSync, mkdtempSync, readdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { ObjectKernel } from '@objectstack/core';
Expand Down Expand Up @@ -144,10 +145,12 @@ async function boot(opts: {
gate?: Promise<void>;
holdFrom?: number;
before?: (f: FixturePlugin) => Promise<void>;
/** The database file; the case's own `file` unless named. */
on?: string;
} = {}) {
const kernel = new ObjectKernel({ logger: { level: 'silent' } });
kernels.push(kernel);
const fixture = new FixturePlugin(file, opts);
const fixture = new FixturePlugin(opts.on ?? file, opts);
const plugin = new PinyinSearchPlugin({ enabled: true });
await kernel.use(new ObjectQLPlugin());
await kernel.use(fixture);
Expand Down Expand Up @@ -180,28 +183,63 @@ function history(): Array<Record<string, unknown>> {
}

// [#25749] Each case pays for real-kernel boots over a SQLite file (two in
// most) and at least one full 2,500-row reconcile walk; the forced-rebuild
// case walks twice. Idle, the slowest case takes about 1 s. On a 4-core box
// most) and a full 2,500-row walk. Since #23181 the first walk three cases
// share runs once, in the describe's hook; those cases boot over a copy of
// what it left, and the forced-rebuild case still walks every row in its
// explicit rebuild. Idle, the slowest case took about 1 s. On a 4-core box
// with 20 busy loops the boots and walks alone took 6.8 s and 10.1 s (seed
// excluded), past vitest's 5 s default, so the suite carries its own budget.
describe('#25634 the boot reconcile reads what changed since its last completed walk', { timeout: 30_000 }, () => {
// [#23181] The first boot over the 2,500-row history, walked to completion
// with the ledger on, is the same setup in three cases. It runs ONCE, here,
// and its readings are taken while it is up; each of those cases then boots
// over its own copy of the database that walk left (`fromWalked`).
let walkedDir: string;
let walked: string;
let firstReads: Reads;
let firstCjkSearch: unknown;
let firstLatinSearch: unknown;
let firstReceipt: any;

beforeAll(async () => {
vi.stubEnv('OS_SEARCH_PINYIN_ENABLED', 'true');
walkedDir = mkdtempSync(join(tmpdir(), 'pinyin-reconcile-walked-'));
walked = join(walkedDir, 'db.sqlite');
const first = await boot({ on: walked, before: (f) => f.directInsert(history()) });
try {
await first.plugin.whenReconciled();
firstReads = { ...first.fixture.reads };
firstCjkSearch = (await first.fixture.row('p00010'))?.__search;
firstLatinSearch = (await first.fixture.row('p00011'))?.__search ?? null;
firstReceipt = await first.fixture.engine.findOne('sys_migration', {
where: { id: SEARCH_COMPANION_RECONCILE_MIGRATION_ID }, context: SYSTEM,
});
} finally {
await shutdown(first.kernel);
vi.unstubAllEnvs();
}
}, 30_000);

afterAll(() => {
rmSync(walkedDir, { recursive: true, force: true });
});

/** This case's database becomes a copy of the one the first walk left. */
function fromWalked(): void {
for (const name of readdirSync(walkedDir)) copyFileSync(join(walkedDir, name), join(dir, name));
}

it('a first boot walks every row; an unchanged second boot reads none — and a receipt sits in sys_migration', async () => {
const first = await boot({ before: (f) => f.directInsert(history()) });
await first.plugin.whenReconciled();
const firstReads = { ...first.fixture.reads };
expect(firstReads.rows).toBe(ROWS);
expect(firstReads.queries).toBe(Math.ceil(ROWS / PAGE));
// The walk did its job: a CJK history row now carries its companion.
expect((await first.fixture.row('p00010'))?.__search).toBe('zhangwei10 zw10');
expect((await first.fixture.row('p00011'))?.__search ?? null).toBeNull();
const receipt: any = await first.fixture.engine.findOne('sys_migration', {
where: { id: SEARCH_COMPANION_RECONCILE_MIGRATION_ID }, context: SYSTEM,
});
expect(receipt?.blocking).toBe(0);
expect(receipt?.verified_at ?? null).toBeNull();
expect(JSON.parse(receipt.details).objects[PROBE].sources).toEqual(['title']);
await shutdown(first.kernel);
expect(firstCjkSearch).toBe('zhangwei10 zw10');
expect(firstLatinSearch).toBeNull();
expect(firstReceipt?.blocking).toBe(0);
expect(firstReceipt?.verified_at ?? null).toBeNull();
expect(JSON.parse(firstReceipt.details).objects[PROBE].sources).toEqual(['title']);

fromWalked();
const second = await boot();
await second.plugin.whenReconciled();
// THE PIN: the unchanged table is not read again — one filtered query
Expand All @@ -212,10 +250,7 @@ describe('#25634 the boot reconcile reads what changed since its last completed
});

it('a second boot still fills a row a hook-bypassing writer added since the last walk, reading only that row', async () => {
const first = await boot({ before: (f) => f.directInsert(history()) });
await first.plugin.whenReconciled();
await shutdown(first.kernel);

fromWalked();
const second = await boot({
before: (f) => f.directInsert([{ id: 'p_new', title: '李娜', created_at: new Date().toISOString(), updated_at: new Date().toISOString() }]),
});
Expand All @@ -236,7 +271,10 @@ describe('#25634 the boot reconcile reads what changed since its last completed
});

it('control: the explicit rebuild (force) still walks every row, and fills a row the watermark cannot see', async () => {
const first = await boot({ before: (f) => f.directInsert(history()) });
// Booted over the walked history: the watermark that walk recorded is the
// one the back-dated write below hides from.
fromWalked();
const first = await boot();
await first.plugin.whenReconciled();
// A hook- AND stamp-bypassing write that back-dates itself: no watermark
// can see it. That is what the explicit rebuild is for.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
// test-budget: 20 — spawns the real producer once per single-cause fixture: a fresh real ObjectQL engine on its own better-sqlite3 :memory: database per fixture, most seeded row by row through a natural-order facade (populations of up to 201 users) before the real promotion pass, each population and order its case's subject; slowest CI reading 13.5 s (scheduled full-battery run 41255489006, Test Core shard log) x 1.25, rounded up; read before the card-shape passes were shared by the per-order and same-answer cases

/**
* Commit 9b9581b11 — WHICH user the `single`-posture bootstrap promotes, and why.
Expand Down Expand Up @@ -59,7 +60,7 @@
* back to being a function of the driver.
*/

import { describe, it, expect, afterEach, beforeEach } from 'vitest';
import { describe, it, expect, afterEach, beforeAll, beforeEach } from 'vitest';
import { assertEngineUpdateDispatch } from '@objectstack/metadata-core';
import { ObjectQL } from '@objectstack/objectql';
import { SqlDriver } from '@objectstack/driver-sql';
Expand Down Expand Up @@ -318,33 +319,44 @@ describe('#16682 — the promotion target is chosen, not sampled', () => {
// ─────────────────────────────────────────────────────────────────────────

describe('the card\'s shape: >50 users, owner last by id and first by created_at', () => {
for (const order of NATURAL_ORDERS) {
it(`promotes the oldest authenticable human under natural order ${order}`, async () => {
// [#23181] One pass per natural order — a fresh engine, the card's
// population seeded through that order's facade, then the promotion — is
// the setup of both the per-order cases and the same-answer case, so it
// runs once per order, here, and the cases read what each pass produced.
const passes = new Map<
NaturalOrder,
{ report: Awaited<ReturnType<typeof bootstrapPlatformAdmin>>; info: string[]; grants: string[] }
>();

beforeAll(async () => {
delete process.env[OWNER_ENV];
resetPlatformAdminEmailMemo();
for (const order of NATURAL_ORDERS) {
const engine = await boot();
const ql = withNaturalOrder(engine, order);
await seedCardPopulation(ql);

const { info, logger } = collectingLogger();
const report = await bootstrapPlatformAdmin(ql, defaultPermissionSets, { logger });
passes.set(order, { report, info, grants: await adminGrantEmails(engine) });
}
}, 30_000);

for (const order of NATURAL_ORDERS) {
it(`promotes the oldest authenticable human under natural order ${order}`, () => {
const { report, info, grants } = passes.get(order)!;

expect(report.adminPromoted).toBe(true);
expect(report.basis).toBe('oldest-authenticable');
expect(await adminGrantEmails(engine)).toEqual(['admin@objectos.ai']);
expect(grants).toEqual(['admin@objectos.ai']);
expect(info.join('\n')).toContain(
'first user promoted to platform admin: admin@objectos.ai',
);
});
}

it('gives the SAME answer under all three orders (the driver may not decide this)', async () => {
const answers: string[] = [];
for (const order of NATURAL_ORDERS) {
const engine = await boot();
const ql = withNaturalOrder(engine, order);
await seedCardPopulation(ql);
await bootstrapPlatformAdmin(ql, defaultPermissionSets, {});
answers.push((await adminGrantEmails(engine)).join(','));
}
it('gives the SAME answer under all three orders (the driver may not decide this)', () => {
const answers = NATURAL_ORDERS.map((order) => passes.get(order)!.grants.join(','));
expect(answers).toEqual(['admin@objectos.ai', 'admin@objectos.ai', 'admin@objectos.ai']);
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
// test-budget: 20 — spawns the real producer once per single-cause fixture: 25 cases on 39 real-stack boots (better-sqlite3 :memory:, the real sys_metadata objects, protocol and routes; about 12 ms each, 0.5 s of the file), each case's own save, read, publish and discard sequence on the real /meta routes being its subject, no two the same; slowest CI reading 15.0 s (scheduled full-battery run 41271604267, Test Core shard log) x 1.25, rounded up

/**
* [#22128] One head row per `/meta` write address: the save's expected-parent
Expand Down
1 change: 1 addition & 0 deletions packages/verify/src/harness.key-custody.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
// test-budget: 20 — spawns the real producer once per single-cause fixture: five in-process bootStack boots, each the subject of its cases and no two the same setup (an empty key home, a key file already in the home, an OS_SECRET_KEY in the environment, and a restart pair over one database file), every boot in a hook and none warmed up at module scope; slowest CI reading 14.2 s (scheduled full-battery run 41199450735, Test Core shard log) x 1.25, rounded up
//
// [#21499] `bootStack` never creates key material in the key home, and never
// seals under a real key the host already holds.
Expand Down
Loading
Loading