Skip to content

Commit 630b407

Browse files
test(plugin-security): one stack per policy and two shared baseline trees in the last two slow security suites (test budget, slice 22) (#28425)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoBeGYBWERTvqddUhodNHgAv)_ Part of #23181 (slice 22, `@objectstack/plugin-security`). The card is a series and stays open. Clause-②: no Metadata-protocol: none Test-only diff: `skip-changeset`. Two test files under `packages/plugins/plugin-security/src/`; `@objectstack/plugin-security`'s `files[]` ships `dist`, `README.md` and `CHANGELOG.md` only. ## 维护者速读 - 改了什么:plugin-security 包 2 个测试文件。`rls-check-stored-form` 里写同一条策略的用例按驱动共用一个已启动的栈(启动次数 90 → 50),每个用例只写、只读自己的 id;`default-row-scope-declarations` 的四个用例共用两棵只读的基线树(启动 7 → 2)。两个文件都在模块顶层先付首次加载,后者在第 2 行加 `// test-budget:` 声明。只动测试文件,不改断言。 - 为什么改:卡 #23181 要求单个测试文件不超过 10 s;最新一次定时全量运行(41199450735)里 plugin-security 整包超出套件上限 1.15 倍,上一片之后包内剩下的两个超 10 s 文件就是这两个:13.98 s、12.82 s。 - 风险与回滚:断言、超时、分层都没动;两个文件共 136 个用例名前后一致、全绿;三处消融(存储形态、JSON 列拒绝、对象默认行范围)改前与改后红的是同一组用例。回滚即 revert 本 PR。 - 先看:`rls-check-stored-form.test.ts` 的 `stackFor()` 注释(为什么写入的用例也能共用栈:各写各的 id,日志从自己的标记读起)。 - 两分钟验证:`pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2 src/rls-check-stored-form.test.ts`,预期 104 个用例全绿,耗时约为 main 上的一半。 - 需要你做的:无,席位落地。 ## What changes **The reading.** The newest full-battery reading is the scheduled CI run 41199450735 (2026-10-31T12:04Z, `63a9bcb773`, before slice 21 landed); its `Test Core` suite-ceiling report puts `@objectstack/plugin-security` over its ceiling (547.25 s against 474.43 s, 1.15x). Of the five files it names over 10 s, slice 21 (#28421) took three; this slice takes the other two: `src/objects/default-row-scope-declarations.test.ts` 13.98 s and `src/rls-check-stored-form.test.ts` 12.82 s (11.72 s and 15.50 s in run 41183072927). The report names no other file over 10 s in the packages it lists. **`rls-check-stored-form`: one stack per policy.** Each of the 45 cells per driver booted its own `ObjectQL` + driver + `SecurityPlugin` for the one RLS policy it names, so about 90 boots ran for 25 distinct policies per driver. Cells now share a stack per (driver, `using`, `check`): `stackFor()`, 50 boots. These cells write, so the sharing rests on isolation rather than on read-only-ness. Each cell's verdict is a per-row predicate over the row it writes. Each cell writes, updates and reads only under ids of its own (`ids()`). The one predicate update matches `title: 'batch'`, which only that cell's own row carries. The refusal cells read the plugin's server log from their own mark on (`warnMark` / `refusalLines(logger, mark)`), so an earlier cell's refusal line is never counted. The first boot, write and read on each driver now run at module scope on a throwaway stack (AGENTS.md: clocked windows measure behaviour, never loading). **`default-row-scope-declarations`: two shared trees.** The four baseline cases booted seven stacks, a declared and an undeclared tree each time. None of them writes after the fixture is seeded (they find, explain, or read the collection), so `baseline(declared)` boots each tree once and the four cases share it. A throwaway tree answers one find and one explain at module scope. What remains is the first case's own work: every persona's read and explain of every declarer the sets carry, on both trees (about 3 s locally). So the file declares a budget. **Budget header (ruling record 6181973363, letter B).** `default-row-scope-declarations` is about 3.1x its local clock on CI, which puts it near the line after this change. It is a real plugin over a real engine and SQL driver per tree, so it gets one line at line 2: the slowest CI reading x 1.25, rounded up to the next 10 s. `rls-check-stored-form` reads about 2.5x local on CI, about 6.4 s after this change, so it takes no header. | file | change | slowest CI reading (s) | budget | |:--|:--|--:|--:| | `src/rls-check-stored-form.test.ts` | one stack per policy, own ids, own log mark, module-scope first boot | 15.50 | none (the 10 s line) | | `src/objects/default-row-scope-declarations.test.ts` | two shared read-only trees, module-scope first boot, header | 13.98 | 20 | ## Tests Per file, vitest's own file clock (what the refresh lane reads), 3 interleaved A/B rounds, each file in its own run, `--maxWorkers=2`, under the shared verify lock on a shared container. Before is the file at `472351944`, after is this head. | file | before r1/r2/r3 (s) | after r1/r2/r3 (s) | |:--|:--|:--| | rls-check-stored-form | 5.41 / 4.64 / 5.11 | 2.47 / 2.60 / 2.54 | | default-row-scope-declarations | 4.53 / 4.84 / 4.33 | 3.86 / 3.20 / 3.53 | Name census over the A/B JSON reports, before vs after: 104 / 32 test names on both sides in every round, 0 lost, 0 new, 0 with a changed status. Whole package on this head: `Test Files 238 passed (238)`, `Tests 5125 passed | 45 skipped (5170)`. <details><summary>Ablations: sharing the stacks keeps every cell's red</summary> Each mutation went through `scripts/ablation-replace.mjs` with the marker counted on disk, and was restored with the blob equal to HEAD. Each was run against the base file (`472351944`) and against this head: | mutation | file | red, base | red, head | same set of names | |:--|:--|--:|--:|:--| | `storedFormImage` answers the image as written (`src/rls-check-stored-form.ts`) | rls-check-stored-form | 27 / 104 | 27 / 104 | yes | | `findJsonColumnCheckRefusal` never refuses | rls-check-stored-form | 24 / 104 | 24 / 104 | yes | | `objectDefaultRowScopePolicy` builds no default (`src/object-default-row-scope.ts`) | default-row-scope-declarations | 3 / 32 | 3 / 32 | yes | The first two are the write-sharing check. If a wrongly admitted write on a shared stack could leak into a later cell, head would go red on more cells than base. It reds the same set. </details> <details><summary>Header parse</summary> `readTestBudget` (`scripts/measure-test-shard-timings.mjs`) reads `default-row-scope-declarations` as budget 20 at line 2, class `spawns the real producer once per single-cause fixture`, and `rls-check-stored-form` as declaring none. </details> ## Acceptance notes - The reading predates slice 21's merge, so it still shows that slice's three files at their old times. They carry their own headers since #28421. - `rls-check-stored-form`'s remaining time is the 50 boots and each cell's own write and reads. Sharing further (one stack per driver with one object per policy) would change the fixture each policy is judged in, so it is left out. - Branch `claude/project-thread-avx4e9` is the card thread's platform-assigned branch, restarted from `main` after #28421 merged; claim 6544453637 names it. ## Verification Local, on `405330d4e`: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 52 families. Each was run with `</dev/null`, and its exit was captured by redirect before any pipe. All 52 exit 0. `--ran`: `52 derived famil(ies) accounted for — 52 run, 0 NOT-MEASURED`, exit 0. Scoped lint, the root `lint` script's own command over `packages/plugins/plugin-security`: exit 0. `pnpm --filter @objectstack/plugin-security typecheck`: exit 0 (`check:test-typecheck: OK`, 0 errors held). Whole package: 238/238 files green. --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibxnQNCbvYbRCUqNNvt)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4bc077d commit 630b407

2 files changed

Lines changed: 126 additions & 48 deletions

File tree

‎packages/plugins/plugin-security/src/objects/default-row-scope-declarations.test.ts‎

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
// test-budget: 20 — spawns the real producer once per single-cause fixture: a real SecurityPlugin over a real ObjectQL engine and SQL driver on each of two trees (the identity objects with and without their declarations), every persona's secured read and explain of every declarer compared across the two; slowest CI reading 13.98 s (scheduled full-battery run 41199450735, Test Core suite-ceiling report) x 1.25, rounded up
23

34
/**
45
* [ADR-0095 D1, 2026-10-18 note; ruling of record 6250748677 on #24164, letter
@@ -473,15 +474,41 @@ async function bootBaseline(declared: boolean) {
473474
return { engine, plugin: plugin as any, security, served };
474475
}
475476

477+
/**
478+
* One stack per tree, shared by the four cases below: none of them writes after
479+
* the fixture is seeded — each one finds, explains or reads the collection — so
480+
* a later case asks the same store an earlier one asked.
481+
*/
482+
const baselines = new Map<boolean, ReturnType<typeof bootBaseline>>();
483+
function baseline(declared: boolean) {
484+
let stack = baselines.get(declared);
485+
if (!stack) {
486+
stack = bootBaseline(declared);
487+
baselines.set(declared, stack);
488+
}
489+
return stack;
490+
}
491+
492+
// The first boot pays the cold paths (the identity objects' module graph, the
493+
// driver's first statements, the plugin's first explain): paid here, on a
494+
// throwaway stack, out of the first case's clock.
495+
{
496+
const warm = await bootBaseline(true);
497+
await warm.served('sys_user', BASELINE_PERSONAS.member);
498+
await warm.security.explain({ object: 'sys_user', operation: 'read' }, { ...BASELINE_PERSONAS.member });
499+
baselineEngines.splice(baselineEngines.indexOf(warm.engine), 1);
500+
await warm.engine.destroy();
501+
}
502+
476503
describe('a deployment with the shipped baseline is byte-for-byte unchanged on the real declarations where the sets carry the literal (ruling E′ item 6, ③.11)', () => {
477504
// Explain is read for `read`; the census below reads the collection itself,
478505
// the one source every door and every explain composes from, for all four.
479506
const EXPLAINED = ['read'] as const;
480507
const COLLECTED = ['select', 'insert', 'update', 'delete'] as const;
481508

482509
it('every persona reads every declarer the sets still carry exactly as without the declarations, and is explained the same', { timeout: 30_000 }, async () => {
483-
const after = await bootBaseline(true);
484-
const before = await bootBaseline(false);
510+
const after = await baseline(true);
511+
const before = await baseline(false);
485512
const rowsMoved: string[] = [];
486513
const explainMoved: string[] = [];
487514
for (const [persona, context] of Object.entries(BASELINE_PERSONAS)) {
@@ -508,8 +535,8 @@ describe('a deployment with the shipped baseline is byte-for-byte unchanged on t
508535
});
509536

510537
it('on the declarers whose copies the sets dropped, the declaration is the scope: without it the baseline member reads every row', { timeout: 30_000 }, async () => {
511-
const after = await bootBaseline(true);
512-
const before = await bootBaseline(false);
538+
const after = await baseline(true);
539+
const before = await baseline(false);
513540
for (const object of Object.keys(DROPPED_COPIES)) {
514541
const now = await after.served(object, BASELINE_PERSONAS.member);
515542
expect(now, object).toEqual(DROPPED_COPIES[object].name.endsWith('_self') ? [`${object}:u_x`] : []);
@@ -518,8 +545,8 @@ describe('a deployment with the shipped baseline is byte-for-byte unchanged on t
518545
});
519546

520547
it('each delegated agent reads its delegator\'s rows on both trees where the sets carry the literal, and under the declaration where they do not: the platform administrator\'s agent reads every row', { timeout: 30_000 }, async () => {
521-
const after = await bootBaseline(true);
522-
const before = await bootBaseline(false);
548+
const after = await baseline(true);
549+
const before = await baseline(false);
523550
const everyUser = [...BASELINE_USERS].sort();
524551
const expected: Record<string, { sys_user: string[]; sys_session: string[]; sys_scim_user: string[] }> = {
525552
'mcp agent for the member': { sys_user: ['u_x', 'u_x2'], sys_session: ['sys_session:u_x'], sys_scim_user: ['sys_scim_user:u_x'] },
@@ -549,7 +576,7 @@ describe('a deployment with the shipped baseline is byte-for-byte unchanged on t
549576
});
550577

551578
it('census: a persona collects a declared default exactly on the dropped copies\' `select` — the baseline carries a policy for every other declarer — and a delegated leg collects none', async () => {
552-
const { plugin } = await bootBaseline(true);
579+
const { plugin } = await baseline(true);
553580
const objects = await registeredObjects();
554581
const census = async (context: Persona): Promise<string[]> => {
555582
const sets = await plugin.resolvePermissionSetsForContext({ ...context });

‎packages/plugins/plugin-security/src/rls-check-stored-form.test.ts‎

Lines changed: 92 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@
5858
* live.
5959
*/
6060

61-
import { describe, it, expect, afterEach, vi } from 'vitest';
61+
import { describe, it, expect, afterEach, afterAll, vi } from 'vitest';
6262
// The mocked module (see `vi.mock` below), loaded at module top.
6363
import { matchesFilterCondition } from '@objectstack/formula';
6464
import { JSON_COLUMN_INCOMPATIBLE_OPERATORS, jsonColumnOperatorRefusalText } from '@objectstack/core';
@@ -137,13 +137,19 @@ afterEach(async () => {
137137
try { await booted.pop()!.destroy(); } catch { /* noop */ }
138138
}
139139
});
140+
const sharedEngines: ObjectQL[] = [];
141+
afterAll(async () => {
142+
while (sharedEngines.length) {
143+
try { await sharedEngines.pop()!.destroy(); } catch { /* noop */ }
144+
}
145+
});
140146

141147
let seq = 0;
142148
/**
143149
* One engine and plugin, with ONE policy whose `using` and `check` are the same
144150
* predicate — or, given `using`, a policy whose `using` is that one instead.
145151
*/
146-
async function boot(makeDriver: () => Driver, predicate: string, using: string = predicate) {
152+
async function boot(makeDriver: () => Driver, predicate: string, using: string = predicate, into: ObjectQL[] = booted) {
147153
const OBJ = `qa_due_stored_${process.pid}_${++seq}`;
148154
const engine = new ObjectQL();
149155
engine.registerDriver(makeDriver() as never, true);
@@ -176,7 +182,7 @@ async function boot(makeDriver: () => Driver, predicate: string, using: string =
176182
// A real `single` install's Default Organization: the system writes below
177183
// derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1).
178184
await installDefaultOrganization(engine);
179-
booted.push(engine);
185+
into.push(engine);
180186

181187
const set = PermissionSetSchema.parse({
182188
name: 'qa_due_guard',
@@ -212,6 +218,42 @@ async function boot(makeDriver: () => Driver, predicate: string, using: string =
212218
return { OBJ, engine, caller, storedRow, shownTo, logger: ctx.logger };
213219
}
214220

221+
/**
222+
* One stack per (driver, `using`, `check`), shared by every cell that names that
223+
* policy. A cell's verdict is a per-row predicate over the row it writes, and every
224+
* cell writes and reads only under ids of its own (`ids()`), so what an earlier
225+
* cell stored on the stack is never a row a later cell writes, updates or reads:
226+
* a predicate update matches `title: 'batch'`, which only that cell's own row
227+
* carries. A cell reads the server log from its own mark on (`warnsSince`).
228+
*/
229+
const sharedStacks = new Map<string, ReturnType<typeof boot>>();
230+
function stackFor(driverName: string, makeDriver: () => Driver, predicate: string, using: string = predicate) {
231+
const key = `${driverName} :: ${using} :: ${predicate}`;
232+
let stack = sharedStacks.get(key);
233+
if (!stack) {
234+
stack = boot(makeDriver, predicate, using, sharedEngines);
235+
sharedStacks.set(key, stack);
236+
}
237+
return stack;
238+
}
239+
let cellSeq = 0;
240+
/** This cell's own row ids: `w` the caller's write, `r` the system's read row, `u`/`p` an update's row. */
241+
const ids = () => {
242+
const n = ++cellSeq;
243+
return { w: `w${n}`, r: `r${n}`, u: `u${n}`, p: `p${n}` };
244+
};
245+
246+
// The first boot and the first write and read on each driver pay the cold paths
247+
// (module graph, the driver's first statement): paid here, out of every cell's
248+
// clock, on a throwaway stack.
249+
for (const [, makeDriver] of DRIVERS) {
250+
const engines: ObjectQL[] = [];
251+
const warm = await boot(makeDriver, "record.title == 'x'", "record.title == 'x'", engines);
252+
await warm.engine.insert(warm.OBJ, { id: 'warm', title: 'x' }, { context: warm.caller } as never);
253+
await warm.shownTo('warm');
254+
for (const engine of engines) await engine.destroy();
255+
}
256+
215257
type Envelope = { code: string; status: number };
216258
const DENIED: Envelope = { code: 'PERMISSION_DENIED', status: 403 };
217259
const envelopeOf = (e: unknown): Envelope => {
@@ -271,50 +313,54 @@ for (const [driverName, makeDriver] of DRIVERS) {
271313
for (const cell of CELLS) {
272314
const verdict = cell.admitted ? 'admitted and shown' : 'refused 403 and hidden';
273315
it(`${cell.predicate}, ${cell.column} written as ${show(cell.value)}: ${verdict}`, async () => {
274-
const r = await boot(makeDriver, cell.predicate);
275-
const written = await outcome(r.engine.insert(r.OBJ, { id: 'w', [cell.column]: cell.value }, { context: r.caller } as never));
316+
const r = await stackFor(driverName, makeDriver, cell.predicate);
317+
const id = ids();
318+
const written = await outcome(r.engine.insert(r.OBJ, { id: id.w, [cell.column]: cell.value }, { context: r.caller } as never));
276319
expect(written).toEqual(cell.admitted ? 'admitted' : DENIED);
277320
// The read under the same predicate, over the same value stored by the system.
278-
await r.engine.insert(r.OBJ, { id: 'r', [cell.column]: cell.value }, { context: SYS_CTX } as never);
279-
expect((await r.storedRow('r'))?.[cell.column]).toEqual(cell.stored);
280-
expect(await r.shownTo('r')).toBe(cell.admitted);
321+
await r.engine.insert(r.OBJ, { id: id.r, [cell.column]: cell.value }, { context: SYS_CTX } as never);
322+
expect((await r.storedRow(id.r))?.[cell.column]).toEqual(cell.stored);
323+
expect(await r.shownTo(id.r)).toBe(cell.admitted);
281324
// A refused write stored nothing; an admitted one stored the same row.
282-
const w = await r.storedRow('w');
325+
const w = await r.storedRow(id.w);
283326
expect(w === undefined ? undefined : w[cell.column]).toEqual(cell.admitted ? cell.stored : undefined);
284327
});
285328
}
286329

287330
it("a by-id update judges the stored form too: onto the day as an instant 'admitted', onto the next day 403 and unchanged", async () => {
288-
const r = await boot(makeDriver, "record.due_on == '2026-01-05'");
289-
await r.engine.insert(r.OBJ, { id: 'u', due_on: '2026-01-05' }, { context: SYS_CTX } as never);
290-
expect(await outcome(r.engine.update(r.OBJ, { due_on: '2026-01-05T23:30:00Z' }, { where: { id: 'u' }, context: r.caller } as never)))
331+
const r = await stackFor(driverName, makeDriver, "record.due_on == '2026-01-05'");
332+
const { u } = ids();
333+
await r.engine.insert(r.OBJ, { id: u, due_on: '2026-01-05' }, { context: SYS_CTX } as never);
334+
expect(await outcome(r.engine.update(r.OBJ, { due_on: '2026-01-05T23:30:00Z' }, { where: { id: u }, context: r.caller } as never)))
291335
.toBe('admitted');
292-
expect(await outcome(r.engine.update(r.OBJ, { due_on: new Date('2026-01-06T00:30:00Z') }, { where: { id: 'u' }, context: r.caller } as never)))
336+
expect(await outcome(r.engine.update(r.OBJ, { due_on: new Date('2026-01-06T00:30:00Z') }, { where: { id: u }, context: r.caller } as never)))
293337
.toEqual(DENIED);
294-
expect((await r.storedRow('u'))?.due_on).toBe('2026-01-05');
338+
expect((await r.storedRow(u))?.due_on).toBe('2026-01-05');
295339
});
296340

297341
it("[#21238] a by-id update judges a lone scalar on a multi-valued column as its stored list: 'x' admitted, 'xy' 403 and unchanged", async () => {
298-
const r = await boot(makeDriver, "record.tags.contains('x')");
299-
await r.engine.insert(r.OBJ, { id: 'u', tags: ['x', 'z'] }, { context: SYS_CTX } as never);
300-
expect(await outcome(r.engine.update(r.OBJ, { tags: 'x' }, { where: { id: 'u' }, context: r.caller } as never)))
342+
const r = await stackFor(driverName, makeDriver, "record.tags.contains('x')");
343+
const { u } = ids();
344+
await r.engine.insert(r.OBJ, { id: u, tags: ['x', 'z'] }, { context: SYS_CTX } as never);
345+
expect(await outcome(r.engine.update(r.OBJ, { tags: 'x' }, { where: { id: u }, context: r.caller } as never)))
301346
.toBe('admitted');
302-
expect((await r.storedRow('u'))?.tags).toEqual(['x']);
303-
expect(await outcome(r.engine.update(r.OBJ, { tags: 'xy' }, { where: { id: 'u' }, context: r.caller } as never)))
347+
expect((await r.storedRow(u))?.tags).toEqual(['x']);
348+
expect(await outcome(r.engine.update(r.OBJ, { tags: 'xy' }, { where: { id: u }, context: r.caller } as never)))
304349
.toEqual(DENIED);
305-
expect((await r.storedRow('u'))?.tags).toEqual(['x']);
306-
expect(await r.shownTo('u')).toBe(true);
350+
expect((await r.storedRow(u))?.tags).toEqual(['x']);
351+
expect(await r.shownTo(u)).toBe(true);
307352
});
308353

309354
it("[#21238] a predicate update judges a lone scalar on a multi-valued column as its stored list: 'x' admitted, 'xy' 403 and unchanged", async () => {
310-
const r = await boot(makeDriver, "record.tags.contains('x')");
311-
await r.engine.insert(r.OBJ, { id: 'p', title: 'batch', tags: ['x'] }, { context: SYS_CTX } as never);
355+
const r = await stackFor(driverName, makeDriver, "record.tags.contains('x')");
356+
const { p } = ids();
357+
await r.engine.insert(r.OBJ, { id: p, title: 'batch', tags: ['x'] }, { context: SYS_CTX } as never);
312358
expect(await outcome(r.engine.update(r.OBJ, { tags: 'x' }, { where: { title: 'batch' }, multi: true, context: r.caller } as never)))
313359
.toBe('admitted');
314-
expect((await r.storedRow('p'))?.tags).toEqual(['x']);
360+
expect((await r.storedRow(p))?.tags).toEqual(['x']);
315361
expect(await outcome(r.engine.update(r.OBJ, { tags: 'xy' }, { where: { title: 'batch' }, multi: true, context: r.caller } as never)))
316362
.toEqual(DENIED);
317-
expect((await r.storedRow('p'))?.tags).toEqual(['x']);
363+
expect((await r.storedRow(p))?.tags).toEqual(['x']);
318364
});
319365
});
320366
}
@@ -358,41 +404,45 @@ const JSON_COLUMN_CELLS: JsonColumnCell[] = [
358404
{ predicate: "record.title in ['x']", column: 'title', value: 'x', admitted: true, stored: 'x' },
359405
];
360406

361-
/** The write gate's server-log lines for this refusal. */
362-
const refusalLines = (logger: { warn: unknown }): string[] =>
363-
(logger.warn as ReturnType<typeof vi.fn>).mock.calls.map((c) => String(c[0])).filter((l) => l.includes('RLS check REFUSED'));
407+
/** Where the shared stack's server log stands before this cell writes. */
408+
const warnMark = (logger: { warn: unknown }): number => (logger.warn as ReturnType<typeof vi.fn>).mock.calls.length;
409+
/** The write gate's server-log lines for this refusal: this cell's, from its mark on. */
410+
const refusalLines = (logger: { warn: unknown }, mark: number): string[] =>
411+
(logger.warn as ReturnType<typeof vi.fn>).mock.calls.slice(mark).map((c) => String(c[0])).filter((l) => l.includes('RLS check REFUSED'));
364412

365413
for (const [driverName, makeDriver] of DRIVERS) {
366414
describe(`[#21254] ${driverName}: an operator the read refuses on a declared JSON-stored column is refused by the write check, with the read's answer`, () => {
367415
for (const cell of JSON_COLUMN_CELLS) {
368416
const verdict = cell.refused ? 'refused 400 INVALID_FILTER, as the read is' : cell.admitted ? 'admitted and shown' : 'refused 403 and hidden';
369417
it(`${cell.predicate}, ${cell.column} written as ${show(cell.value)}: ${verdict}`, async () => {
370-
const r = await boot(makeDriver, cell.predicate);
418+
const r = await stackFor(driverName, makeDriver, cell.predicate);
419+
const id = ids();
420+
const mark = warnMark(r.logger);
371421
const written = await r.engine
372-
.insert(r.OBJ, { id: 'w', [cell.column]: cell.value }, { context: r.caller } as never)
422+
.insert(r.OBJ, { id: id.w, [cell.column]: cell.value }, { context: r.caller } as never)
373423
.then(() => 'admitted' as const, (e: unknown) => e);
374-
await r.engine.insert(r.OBJ, { id: 'r', [cell.column]: cell.value }, { context: SYS_CTX } as never);
424+
await r.engine.insert(r.OBJ, { id: id.r, [cell.column]: cell.value }, { context: SYS_CTX } as never);
375425
if (!cell.refused) {
376426
expect(written === 'admitted' ? written : envelopeOf(written)).toEqual(cell.admitted ? 'admitted' : DENIED);
377-
expect((await r.storedRow('r'))?.[cell.column]).toEqual(cell.stored);
378-
expect(await r.shownTo('r')).toBe(cell.admitted);
379-
expect(refusalLines(r.logger)).toEqual([]);
427+
expect((await r.storedRow(id.r))?.[cell.column]).toEqual(cell.stored);
428+
expect(await r.shownTo(id.r)).toBe(cell.admitted);
429+
expect(refusalLines(r.logger, mark)).toEqual([]);
380430
return;
381431
}
382432
const [field, op, bare] = cell.refused;
383433
const words = jsonColumnOperatorRefusalText(field, op, bare);
384434
// The write: the read's code and status, and core's words, which withhold the field and the operator.
385435
expect(envelopeOf(written)).toEqual(REFUSED);
386436
expect((written as Error).message).toBe(words.message);
387-
expect(await r.storedRow('w')).toBeUndefined();
437+
expect(await r.storedRow(id.w)).toBeUndefined();
388438
// The diagnostic the message points to is in the server log, beside the policy.
389-
const lines = refusalLines(r.logger);
439+
const lines = refusalLines(r.logger, mark);
390440
expect(lines).toHaveLength(1);
391441
expect(lines[0]).toContain("policy 'due_guard'");
392442
expect(lines[0]).toContain(words.diagnostic);
393443
// The read the same policy scopes, over the same value stored by the system: the same answer.
394444
const read = await r.engine
395-
.find(r.OBJ, { where: { id: 'r' }, context: r.caller } as never)
445+
.find(r.OBJ, { where: { id: id.r }, context: r.caller } as never)
396446
.then(() => 'answered' as const, (e: unknown) => e);
397447
expect(envelopeOf(read)).toEqual(REFUSED);
398448
expect((read as Error).message).toBe(words.message);
@@ -403,13 +453,14 @@ for (const [driverName, makeDriver] of DRIVERS) {
403453
// predicate an update's pre-image read is refused first, by the driver,
404454
// and its gate fails closed 403 before any check runs.
405455
it("record.tags != 'x' as the check: a by-id update and a predicate update are refused 400 too, and change nothing", async () => {
406-
const r = await boot(makeDriver, "record.tags != 'x'", "record.title == 'batch'");
407-
await r.engine.insert(r.OBJ, { id: 'u', title: 'batch', tags: ['y'] }, { context: SYS_CTX } as never);
408-
expect(await outcome(r.engine.update(r.OBJ, { tags: 'x' }, { where: { id: 'u' }, context: r.caller } as never)))
456+
const r = await stackFor(driverName, makeDriver, "record.tags != 'x'", "record.title == 'batch'");
457+
const { u } = ids();
458+
await r.engine.insert(r.OBJ, { id: u, title: 'batch', tags: ['y'] }, { context: SYS_CTX } as never);
459+
expect(await outcome(r.engine.update(r.OBJ, { tags: 'x' }, { where: { id: u }, context: r.caller } as never)))
409460
.toEqual(REFUSED);
410461
expect(await outcome(r.engine.update(r.OBJ, { tags: ['x'] }, { where: { title: 'batch' }, multi: true, context: r.caller } as never)))
411462
.toEqual(REFUSED);
412-
expect((await r.storedRow('u'))?.tags).toEqual(['y']);
463+
expect((await r.storedRow(u))?.tags).toEqual(['y']);
413464
});
414465
});
415466
}

0 commit comments

Comments
 (0)