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
1 change: 0 additions & 1 deletion packages/plugins/plugin-auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@
"jose": "^6.2.5"
},
"devDependencies": {
"@objectstack/driver-memory": "workspace:*",
"@objectstack/driver-sql": "workspace:*",
"@objectstack/objectql": "workspace:*",
"@types/node": "^26.1.2",
Expand Down
199 changes: 171 additions & 28 deletions packages/plugins/plugin-auth/src/auth-contains-filter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* `convertWhere()` used to emit `{ field: { $regex: condition.value } }`, which
* puts an unescaped, caller-supplied comparand (`/admin/list-users`'
* `searchValue`, a SCIM filter value) into a PATTERN position. What that value
* then means depended on the backend under the auth path:
* then meant depended on the backend under the auth path:
*
* - driver-memory compiled it to `new RegExp(value)` — `a.b` matched `axb`,
* `^x` anchored, and an unbalanced `(` was an illegal pattern;
Expand All @@ -20,37 +20,101 @@
* member of the spec's `FILTER_OPERATORS`, i.e. one every backend is required
* to evaluate, as a literal substring.
*
* Two faces, deliberately: the first pins WHAT the adapter emits (the contract),
* the second pins what a real backend then ANSWERS (the behaviour). The first
* alone cannot see a translation that is spelled right and evaluated wrong; the
* second alone cannot say which operator earned the result.
* Three faces, deliberately: the first pins WHAT the adapter emits (the
* contract), the second pins what a real backend then ANSWERS (the behaviour),
* and the third pins the property that lets the second face FAIL at all (the
* discrimination). The first alone cannot see a translation that is spelled
* right and evaluated wrong; the second alone cannot say which operator earned
* the result; and without the third, face 2 would be an always-green pin the
* day the backend starts aliasing `$regex` again.
*
* ## Backend note (#5893 / #5830 / #5704)
*
* Face 2's backend was `InMemoryDriver` when this file landed with #5812; it is
* now `@objectstack/driver-sql` + better-sqlite3 `:memory:`, the harness its
* sibling `auth-where-operator-coverage.test.ts` already uses (PR #5880), built
* the way the rest of the repo builds an ephemeral store (`examples/app-crm`,
* `cli db clean`, PR #5715's `makeDefaultDriver()`).
*
* The swap was DEFERRED once, on measurement, and this is the deferral being
* discharged rather than forgotten — the history matters because it names the
* exact hazard this file now guards against:
*
* - **Then (#5830, PR #5880).** driver-sql routed `$regex` through the same
* `applyContainsLike` as `$contains` (a `case '$regex':` fallthrough), so
* the SQL backend answered the two operators cell-for-cell alike. Measured
* there: with the defect restored, memory failed 3 of these behavioural
* pins and sqlite passed all 4. Migrating then would have produced pins
* that are green because nothing distinguishes them — coverage that is
* blind to the very defect it names.
* - **Now (#5702, PR #6549).** The fallthrough is deleted and `$regex` is
* RETIRED: driver-sql refuses it by name in the ADR-0112 envelope
* (`code: 'INVALID_FILTER'`, `status: 400`), prescribing `$icontains`. The
* defect is therefore witnessed on the SQL arm again — not as a different
* row set, but as a REFUSAL, which is a strictly better reason: a bare
* `$regex` from this adapter no longer answers a subtly wrong question, it
* answers nothing and says why.
*
* Re-measured for #5893 before this file moved (both directions, `flock`-ed
* local run, and again with the defect restored):
*
* - fixed adapter, sqlite: `$contains 'a.b'` → `['a.b']`, `'^a'` → `[]`,
* `'('` → `['x(y']`, `'xb'` → `['axb']` — identical, value for value, to
* what memory answered, so the migration costs the fixture nothing;
* - defect restored (adapter emits a bare `$regex` again), sqlite: all four
* behavioural pins RED via the refusal, plus the contract face.
*
* Face 3 exists so that this stays true without anyone re-measuring by hand. It
* is the load-bearing half of the migration: if driver-sql ever re-aliases
* `$regex` onto the substring path, face 3 goes red and says so, instead of
* face 2 quietly reverting to the always-green pin #5830 refused to create.
*
* Case semantics are deliberately untested here, exactly as in the sibling
* file: sqlite's `LIKE` is ASCII case-INsensitive by default (so `$contains`
* folds on this backend while the contract layer calls it case-SENSITIVE per
* #5701 Q2=A — driver-sql pins that gap itself, in
* `sql-driver-icontains-and-retired-operators.test.ts`, which asserts
* `$contains` compiles WITHOUT `LOWER()` while `$icontains` compiles with it).
* Every fixture below is lower-case and no assertion depends on which way that
* per-driver alignment lands.
*/

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { InMemoryDriver } from '@objectstack/driver-memory';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { SqlDriver } from '@objectstack/driver-sql';
import { FILTER_OPERATORS } from '@objectstack/spec/data';
import type { QueryAST } from '@objectstack/spec/data';
import type { IDataEngine } from '@objectstack/core';
import { createObjectQLAdapterFactory } from './objectql-adapter';

/** Keeps the driver's own lifecycle logging out of the test output. */
const silentLogger = {
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
} as any;
/**
* The columns face 2 reads, declared — a real table has to be told.
*
* `emailVerified` / `createdAt` / `updatedAt` used to be seeded alongside these
* and are gone with the backend swap: they were camelCase keys no assertion
* ever read, which only a schemaless store would have accepted (`sys_user`
* spells them `email_verified` / `created_at` / `updated_at`). Declaring the
* fixture down to what it actually asserts on is #5806's "resolve by declaring,
* not by relaxing" — the alternative would be declaring three columns to hold
* values nothing looks at.
*/
const SYS_USER = {
name: 'sys_user',
fields: {
name: { type: 'text', name: 'name' },
email: { type: 'text', name: 'email' },
},
};

/**
* A read-only engine facade over a REAL `InMemoryDriver`.
* A read-only engine facade over a REAL `SqlDriver`.
*
* Only the three read verbs the `contains` path uses are declared. The write
* verbs are deliberately absent rather than stubbed: seeding goes through the
* driver directly (below), so a hand-written `delete`/`update` here would be a
* dispatch contract this test neither needs nor is able to honour
* (`check:engine-double-contract`, #4550).
*/
function memoryReadEngine(driver: InMemoryDriver): IDataEngine {
function sqlReadEngine(driver: SqlDriver): IDataEngine {
// The query bag is forwarded with its declared driver-side type and no `any`
// erasure: `query-options/no-any-erasure` (#4674/#4918) counts a test-side
// `find(obj, … as any)` too, and nothing here needs to be off-contract.
Expand All @@ -61,23 +125,41 @@ function memoryReadEngine(driver: InMemoryDriver): IDataEngine {
} as unknown as IDataEngine;
}

const NOW = new Date('2026-08-06T00:00:00.000Z').toISOString();

/** Rows whose `name`s differ only in how a regex would read the comparand. */
const SEED = [
{ id: 'u_literal', name: 'a.b', email: 'literal@example.com' },
{ id: 'u_wildcard', name: 'axb', email: 'wildcard@example.com' },
{ id: 'u_paren', name: 'x(y', email: 'paren@example.com' },
];

async function seededAdapter() {
const driver = new InMemoryDriver({ logger: silentLogger });
await driver.connect();
for (const row of SEED) {
await driver.create('sys_user', { ...row, emailVerified: false, createdAt: NOW, updatedAt: NOW });
/**
* Live `:memory:` databases, closed after each test — the database dies with
* its connection, so nothing touches the host filesystem, but a file this size
* would otherwise hold one open pool per behavioural case.
*/
const openDrivers: SqlDriver[] = [];

afterEach(async () => {
while (openDrivers.length) {
const driver = openDrivers.pop();
try { await driver?.disconnect(); } catch { /* noop */ }
}
const adapter: any = (createObjectQLAdapterFactory(memoryReadEngine(driver)) as any)({} as any);
return { driver, adapter };
});

async function seededAdapter() {
const driver = new SqlDriver({
client: 'better-sqlite3',
connection: { filename: ':memory:' },
useNullAsDefault: true,
});
openDrivers.push(driver);
// Real DDL through the driver's own path — the table every row below lands in
// is created by the backend, not conjured by a store on first write.
await driver.initObjects([SYS_USER]);
for (const row of SEED) await driver.create('sys_user', row);
const engine = sqlReadEngine(driver);
const adapter: any = (createObjectQLAdapterFactory(engine) as any)({} as any);
return { driver, engine, adapter };
}

/** `findMany` with a single better-auth `contains` condition on `name`. */
Expand All @@ -89,6 +171,10 @@ function containsQuery(value: string) {
} as any;
}

// ---------------------------------------------------------------------------
// Face 1 — the contract: which ObjectQL operator the translation emits
// ---------------------------------------------------------------------------

describe('[#5710] convertWhere: better-auth `contains` → `$contains`', () => {
let engine: IDataEngine;

Expand Down Expand Up @@ -116,8 +202,8 @@ describe('[#5710] convertWhere: better-auth `contains` → `$contains`', () => {
it('emits an operator every backend is required to evaluate', () => {
// The whole point of the flip: `$contains` is in the protocol's runtime
// allowlist, `$regex` never was — it survived only because this adapter
// produced it (driver-memory's `filter-refusal.ts` says so in as many
// words), which is why #5702's loud refusal is ordered after this PR.
// produced it, which is why #5702's loud refusal was ordered after #5812's
// flip and is now the thing face 3 reads.
expect(FILTER_OPERATORS).toContain('$contains');
expect(FILTER_OPERATORS).not.toContain('$regex');
});
Expand All @@ -138,13 +224,18 @@ describe('[#5710] convertWhere: better-auth `contains` → `$contains`', () => {
});
});

// ---------------------------------------------------------------------------
// Face 2 — the behaviour: what a real backend answers
// ---------------------------------------------------------------------------

describe('[#5710] the comparand is a literal substring on a real backend', () => {
it('does not read `.` as a wildcard — `a.b` matches `a.b`, not `axb`', async () => {
const { adapter } = await seededAdapter();
const rows: any[] = await adapter.findMany(containsQuery('a.b'));

// The pin, stated in both directions: the metacharacter row is NOT matched
// (a bare `$regex` matched it through `.`), and the literal row still is.
// (a bare `$regex` matched it through `.` on the memory backend, and is
// refused outright on this one), and the literal row still is.
expect(rows.map((r) => r.name)).toEqual(['a.b']);
});

Expand All @@ -155,6 +246,14 @@ describe('[#5710] the comparand is a literal substring on a real backend', () =>
// As a pattern, `^a` matched `a.b` and `axb`. As a substring, nothing here
// contains the two characters `^a`.
expect(rows).toEqual([]);

// A control read on the SAME fixture, because "no rows" is the one answer a
// broken query and a correct one can both produce: an empty seed, a table
// that never got its DDL, or a predicate the backend silently dropped would
// all satisfy the assertion above. `a` is a substring of two of these three
// rows, so this says the store is live and the predicate really selects.
const control: any[] = await adapter.findMany(containsQuery('a'));
expect(control.map((r) => r.name).sort()).toEqual(['a.b', 'axb']);
});

it('matches a value that is not a legal regex, instead of failing on it', async () => {
Expand All @@ -174,3 +273,47 @@ describe('[#5710] the comparand is a literal substring on a real backend', () =>
expect(rows.map((r) => r.name)).toEqual(['axb']);
});
});

// ---------------------------------------------------------------------------
// Face 3 — the discrimination: this backend can tell the two operators apart
// ---------------------------------------------------------------------------

describe('[#5893] the backend refuses the operator face 2 must never see', () => {
it('refuses a bare `$regex` in the ADR-0112 envelope, naming its replacement', async () => {
const { engine } = await seededAdapter();

// Sent through the SAME engine facade the adapter reads on, so this is the
// literal path a regressed `convertWhere` would take — not a parallel one.
const err: any = await engine
.find('sys_user', { where: { name: { $regex: 'a.b' } } })
.then(() => null, (e: unknown) => e);

// `code` AND `status`, never a bare `rejects.toThrow()`: the defect has two
// fields and a throw-only assertion carries one bit. Before #5702 this call
// did not throw at ALL — it ANSWERED, with `['a.b']` — so an assertion that
// only says "the promise rejected" cannot separate "refused with the wrong
// envelope" from "did not refuse at all", which are exactly the two ways
// this guard can rot.
expect(err).toBeInstanceOf(Error);
expect(err.code).toBe('INVALID_FILTER');
expect(err.status).toBe(400);
// The message is the operating instruction: which operator was refused, and
// what to write instead (#5702 prescribes the replacement, not a list).
expect(err.message).toContain('$regex');
expect(err.message).toContain('$icontains');
});

it('answers the operator the adapter DOES emit, on the same fixture', async () => {
const { engine } = await seededAdapter();

// The other half of the pair, and the reason this face is not just a
// driver-sql test living in the wrong package: refusing everything would
// satisfy the case above. `$contains` and `$regex` must be told APART by
// this backend — one answers, the other is refused — because that
// difference is the whole reason face 2 is allowed to live on sqlite
// (#5830 measured the world where it was not, and deferred the migration).
const rows = await engine.find('sys_user', { where: { name: { $contains: 'a.b' } } });

expect((rows as any[]).map((r) => r.name)).toEqual(['a.b']);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,16 @@
* both ways in #5830: re-dropping the `not_in` arm turns the `not_in` pin red
* on sqlite exactly as it did on memory.
*
* Its sibling `auth-contains-filter.test.ts` is NOT interchangeable this way —
* driver-sql compiles `$regex` through the same `applyContainsLike` as
* `$contains` (`sql-driver.ts`, the `case '$regex':` fallthrough), so a SQL
* backend cannot tell #5710's defect from its fix. That file's disposition is
* #5830's open half; do not "finish the job" by copying this harness onto it.
* [#5893] Its sibling `auth-contains-filter.test.ts` was NOT interchangeable
* this way when the note above was written — driver-sql compiled `$regex`
* through the same `applyContainsLike` as `$contains` (`sql-driver.ts`, the
* `case '$regex':` fallthrough), so a SQL backend could not tell #5710's defect
* from its fix, and #5830 deferred it rather than create an always-green pin.
* #5702 (PR #6549) deleted the fallthrough and RETIRED the spelling: `$regex`
* is now refused by name in the ADR-0112 envelope (`INVALID_FILTER` / 400), so
* that file has moved onto this harness too and witnesses its defect through
* the refusal. It carries its own guard for the refusal, because that property
* — not a row difference — is what lets its behavioural pins fail at all.
*/

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
Expand Down
Loading
Loading