From 063f0964515750dd3a9bc113b24faef69eb53bd7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 15:59:32 +0000 Subject: [PATCH] test: conjoin $or/$and with sibling filters in twelve driver doubles (part of #7620) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twelve in-memory WHERE matchers across packages/plugins/plugin-sharing, packages/plugins/plugin-security and packages/runtime returned early on $or/$and, discarding every sibling equality key in the same filter object — a real driver ANDs them. Corrected to the same conjoin-with-siblings shape already used by packages/objectql's six (#7846) and by several already-fixed siblings in these two packages. Measured live-vs-dormant per file via an fs.appendFileSync probe (with a positive control proving it would catch a live case): all twelve are dormant today, for two different reasons. plugin-sharing's six never receive $or/$and at all. plugin-security's five and the one runtime file do receive them, but always as the sole key in their filter object (no sibling ever present alongside), so early-return and conjoin produce identical results in every observed call. No test outcome changes. Re-grepped the issue's file enumeration at this branch's base ref rather than trusting it: plugin-sharing/src/sharing-rule.test.ts was already fixed, and three files this commit touches were never named in the issue (plugin-sharing/src/sharing-service.test.ts, plugin-security/src/check-only-write-scope.test.ts, plugin-security/src/select-only-write-visibility.test.ts). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RDTnVvsgA6cUZ4xFVtPZRy --- ...gin-sharing-security-runtime-conjoin-or.md | 91 +++++++++++++++++++ .../src/authored-row-write-verdict.test.ts | 7 +- .../src/check-only-write-scope.test.ts | 7 +- .../src/row-write-widener-composition.test.ts | 7 +- .../src/select-only-write-visibility.test.ts | 7 +- .../src/vama-write-path-convergence.test.ts | 7 +- .../src/authored-row-write-deferral.test.ts | 7 +- .../plugin-sharing/src/boot-backfill.test.ts | 7 +- .../plugin-sharing/src/bulk-recompute.test.ts | 7 +- .../src/record-share-cascade.test.ts | 7 +- .../src/sharing-service.test.ts | 11 ++- .../src/system-write-skip-notice.test.ts | 7 +- .../share-links-enforcement-context.test.ts | 7 +- 13 files changed, 153 insertions(+), 26 deletions(-) create mode 100644 .changeset/plugin-sharing-security-runtime-conjoin-or.md diff --git a/.changeset/plugin-sharing-security-runtime-conjoin-or.md b/.changeset/plugin-sharing-security-runtime-conjoin-or.md new file mode 100644 index 0000000000..fbf644c7ae --- /dev/null +++ b/.changeset/plugin-sharing-security-runtime-conjoin-or.md @@ -0,0 +1,91 @@ +--- +"@objectstack/plugin-sharing": patch +"@objectstack/plugin-security": patch +"@objectstack/runtime": patch +--- + +test: twelve in-memory driver doubles in `plugin-sharing`, `plugin-security` and +`runtime` conjoin `$or`/`$and` with their sibling filters instead of short-circuiting +(part of #7620) + +Twelve test files across three packages built an in-memory driver whose `WHERE` +matcher **returned early** on `$or` (and usually `$and`), discarding every sibling +equality key in the same object: + +```ts +if (Array.isArray(filter.$or)) return filter.$or.some((f) => matches(row, f)); +if (Array.isArray(filter.$and)) return filter.$and.every((f) => matches(row, f)); +for (const [k, v] of Object.entries(filter)) { /* siblings, never reached */ } +``` + +A real driver ANDs them. So a query mixing an equality key with a top-level `$or` +would have been answered on the `$or` alone, handing back rows the sibling key +would have excluded — not a stricter or looser edge case, a different query, with +the suite staying green while testing it. + +The fix is the ~2-line change already established by `packages/objectql`'s six +(#7846) and by several already-corrected siblings in these same two packages +(`bu-tree-recompute.test.ts`, `recipient-width.test.ts`, `sharing-rule.test.ts`, +`system-caller-inert-grant.test.ts`, `business-unit-graph.test.ts`): fold the +combinator check into a guard that only short-circuits on **failure**, so the +sibling-key loop still runs afterward. + +**This lane's file enumeration differs from the issue body**, which is stale +(confirmed in-thread): `plugin-sharing/src/sharing-rule.test.ts` was already fixed +before this PR, and three files this PR fixes were never named in the issue at all +(`plugin-sharing/src/sharing-service.test.ts`, +`plugin-security/src/check-only-write-scope.test.ts`, +`plugin-security/src/select-only-write-visibility.test.ts` — found by re-grepping +`$or` across both packages' `src/*.test.ts` at this PR's base ref rather than +trusting the issue's list). Files corrected here: + +- `packages/plugins/plugin-sharing/src/`: `authored-row-write-deferral.test.ts`, + `boot-backfill.test.ts`, `bulk-recompute.test.ts`, `record-share-cascade.test.ts`, + `sharing-service.test.ts`, `system-write-skip-notice.test.ts` +- `packages/plugins/plugin-security/src/`: `authored-row-write-verdict.test.ts`, + `check-only-write-scope.test.ts`, `row-write-widener-composition.test.ts`, + `select-only-write-visibility.test.ts`, `vama-write-path-convergence.test.ts` +- `packages/runtime/src/domains/`: `share-links-enforcement-context.test.ts` + +**All twelve are dormant today — measured, not assumed**, via the same +`fs.appendFileSync` probe discipline #7846 used (a `console.log` probe was tried +first, returned nothing, and was correctly distrusted rather than read as "zero +calls"). Per-package results, with a positive control proving the probe would have +caught a live case: + +- `plugin-sharing`'s six: **0 combinator calls out of ~1.52M matcher invocations** + across the six suites (dominated by one bulk-recompute test's own scale; the + other five totalled ~2,755). Positive control: instrumenting the already-fixed + `sharing-rule.test.ts` with the identical probe, then reverting it, recorded 151 + `$or` and 3 `$and` calls in the same run — proof the zero above is a real + absence, not a dead probe. +- `plugin-security`'s five: **not** all-zero like objectql/sharing — `$and` + appears 23–71 times per file and `$or` appears twice in one file + (`authored-row-write-verdict.test.ts`). But every single one of those calls + carried the combinator as the **only** key in its filter object (`other: []`), + so early-return and conjoin produce identical results in every case observed — + dormant in the sense that decides this PR, for a different reason than + objectql/sharing (never invoked, vs. invoked but never mixed with a sibling). +- `runtime`'s one file: 1 `$or` and 10 `$and` calls, same "combinator-only, no + siblings" shape — dormant for the same reason as `plugin-security`. + +No existing test outcome changes anywhere, and none should: `plugin-sharing` +(21 files / 569 tests), `plugin-security` (52 files / 1037 tests) and `runtime` +(151 files / 2317 tests) are green before and after, byte-identical assertions. + +**Operator-support measurement, per the card's ask**: unlike the objectql six +(measured byte-for-byte identical operator support), these twelve are **not** a +single lowest common denominator. `plugin-sharing`'s matchers mostly support +`$in`, several add `$ne` or `$gte`/`$gt` (the tree/graph-shaped ones), and +`sharing-service.test.ts` supports only `$in`. `plugin-security`'s five and the +`runtime` one are the most uniform subset (`$in` only, identical shape). A shared +helper across all sixteen files repo-wide would have to be a strict superset or +force some suites to drop operators they use — a materially different tradeoff +than the objectql lane's "no lowest common denominator to flatten to" finding. + +Deliberately **not** extracted into a shared helper here either, for the same +reason `packages/objectql`'s six gave: keeping each test double's substrate +self-contained so it can fail independently of any other suite's fixture file. +Where a cross-package helper would live, and whether the missing regression guard +is worth adding, are open questions for the PM now that all three lanes of #7620 +have landed — not decided in this PR. diff --git a/packages/plugins/plugin-security/src/authored-row-write-verdict.test.ts b/packages/plugins/plugin-security/src/authored-row-write-verdict.test.ts index b8e979adbd..686e2d8e29 100644 --- a/packages/plugins/plugin-security/src/authored-row-write-verdict.test.ts +++ b/packages/plugins/plugin-security/src/authored-row-write-verdict.test.ts @@ -192,8 +192,11 @@ function makeEngine(opts: { findOneThrows?: boolean } = {}) { }; const matches = (row: any, filter: any): boolean => { if (!filter || typeof filter !== 'object') return true; - if (Array.isArray(filter.$or)) return filter.$or.some((f: any) => matches(row, f)); - if (Array.isArray(filter.$and)) return filter.$and.every((f: any) => matches(row, f)); + // `$or` / `$and` are conjoined WITH their sibling keys, the way a real + // driver ANDs them — a short-circuiting `return` here would discard every + // sibling equality key in the same object. See #7620. + if (Array.isArray(filter.$or) && !filter.$or.some((f: any) => matches(row, f))) return false; + if (Array.isArray(filter.$and) && !filter.$and.every((f: any) => matches(row, f))) return false; for (const [k, v] of Object.entries(filter)) { if (k === '$or' || k === '$and') continue; if (v != null && typeof v === 'object' && '$in' in (v as any)) { diff --git a/packages/plugins/plugin-security/src/check-only-write-scope.test.ts b/packages/plugins/plugin-security/src/check-only-write-scope.test.ts index 9a6864fdb9..8a946e4c65 100644 --- a/packages/plugins/plugin-security/src/check-only-write-scope.test.ts +++ b/packages/plugins/plugin-security/src/check-only-write-scope.test.ts @@ -253,8 +253,11 @@ function makeEngine() { }; const matches = (row: any, filter: any): boolean => { if (!filter || typeof filter !== 'object') return true; - if (Array.isArray(filter.$or)) return filter.$or.some((f: any) => matches(row, f)); - if (Array.isArray(filter.$and)) return filter.$and.every((f: any) => matches(row, f)); + // `$or` / `$and` are conjoined WITH their sibling keys, the way a real + // driver ANDs them — a short-circuiting `return` here would discard every + // sibling equality key in the same object. See #7620. + if (Array.isArray(filter.$or) && !filter.$or.some((f: any) => matches(row, f))) return false; + if (Array.isArray(filter.$and) && !filter.$and.every((f: any) => matches(row, f))) return false; for (const [k, v] of Object.entries(filter)) { if (k === '$or' || k === '$and') continue; if (v != null && typeof v === 'object' && '$in' in (v as any)) { diff --git a/packages/plugins/plugin-security/src/row-write-widener-composition.test.ts b/packages/plugins/plugin-security/src/row-write-widener-composition.test.ts index 1092316028..badc535866 100644 --- a/packages/plugins/plugin-security/src/row-write-widener-composition.test.ts +++ b/packages/plugins/plugin-security/src/row-write-widener-composition.test.ts @@ -218,8 +218,11 @@ function makeEngine() { }; const matches = (row: any, filter: any): boolean => { if (!filter || typeof filter !== 'object') return true; - if (Array.isArray(filter.$or)) return filter.$or.some((f: any) => matches(row, f)); - if (Array.isArray(filter.$and)) return filter.$and.every((f: any) => matches(row, f)); + // `$or` / `$and` are conjoined WITH their sibling keys, the way a real + // driver ANDs them — a short-circuiting `return` here would discard every + // sibling equality key in the same object. See #7620. + if (Array.isArray(filter.$or) && !filter.$or.some((f: any) => matches(row, f))) return false; + if (Array.isArray(filter.$and) && !filter.$and.every((f: any) => matches(row, f))) return false; for (const [k, v] of Object.entries(filter)) { if (k === '$or' || k === '$and') continue; if (v != null && typeof v === 'object' && '$in' in (v as any)) { diff --git a/packages/plugins/plugin-security/src/select-only-write-visibility.test.ts b/packages/plugins/plugin-security/src/select-only-write-visibility.test.ts index 42a7e8f630..ddf606a1a2 100644 --- a/packages/plugins/plugin-security/src/select-only-write-visibility.test.ts +++ b/packages/plugins/plugin-security/src/select-only-write-visibility.test.ts @@ -243,8 +243,11 @@ function makeEngine() { }; const matches = (row: any, filter: any): boolean => { if (!filter || typeof filter !== 'object') return true; - if (Array.isArray(filter.$or)) return filter.$or.some((f: any) => matches(row, f)); - if (Array.isArray(filter.$and)) return filter.$and.every((f: any) => matches(row, f)); + // `$or` / `$and` are conjoined WITH their sibling keys, the way a real + // driver ANDs them — a short-circuiting `return` here would discard every + // sibling equality key in the same object. See #7620. + if (Array.isArray(filter.$or) && !filter.$or.some((f: any) => matches(row, f))) return false; + if (Array.isArray(filter.$and) && !filter.$and.every((f: any) => matches(row, f))) return false; for (const [k, v] of Object.entries(filter)) { if (k === '$or' || k === '$and') continue; if (v != null && typeof v === 'object' && '$in' in (v as any)) { diff --git a/packages/plugins/plugin-security/src/vama-write-path-convergence.test.ts b/packages/plugins/plugin-security/src/vama-write-path-convergence.test.ts index 0429c18f8b..c6fb223812 100644 --- a/packages/plugins/plugin-security/src/vama-write-path-convergence.test.ts +++ b/packages/plugins/plugin-security/src/vama-write-path-convergence.test.ts @@ -102,8 +102,11 @@ function makeEngine() { }; const matches = (row: any, filter: any): boolean => { if (!filter || typeof filter !== 'object') return true; - if (Array.isArray(filter.$or)) return filter.$or.some((f: any) => matches(row, f)); - if (Array.isArray(filter.$and)) return filter.$and.every((f: any) => matches(row, f)); + // `$or` / `$and` are conjoined WITH their sibling keys, the way a real + // driver ANDs them — a short-circuiting `return` here would discard every + // sibling equality key in the same object. See #7620. + if (Array.isArray(filter.$or) && !filter.$or.some((f: any) => matches(row, f))) return false; + if (Array.isArray(filter.$and) && !filter.$and.every((f: any) => matches(row, f))) return false; for (const [k, v] of Object.entries(filter)) { if (k === '$or' || k === '$and') continue; if (v != null && typeof v === 'object' && '$in' in (v as any)) { diff --git a/packages/plugins/plugin-sharing/src/authored-row-write-deferral.test.ts b/packages/plugins/plugin-sharing/src/authored-row-write-deferral.test.ts index a6d90947ac..26d2c2fb34 100644 --- a/packages/plugins/plugin-sharing/src/authored-row-write-deferral.test.ts +++ b/packages/plugins/plugin-sharing/src/authored-row-write-deferral.test.ts @@ -181,8 +181,11 @@ function makeEngine() { }; const matches = (row: any, filter: any): boolean => { if (!filter || typeof filter !== 'object') return true; - if (Array.isArray(filter.$or)) return filter.$or.some((f: any) => matches(row, f)); - if (Array.isArray(filter.$and)) return filter.$and.every((f: any) => matches(row, f)); + // `$or` / `$and` are conjoined WITH their sibling keys, the way a real + // driver ANDs them — a short-circuiting `return` here would discard every + // sibling equality key in the same object. See #7620. + if (Array.isArray(filter.$or) && !filter.$or.some((f: any) => matches(row, f))) return false; + if (Array.isArray(filter.$and) && !filter.$and.every((f: any) => matches(row, f))) return false; for (const [k, v] of Object.entries(filter)) { if (k === '$or' || k === '$and') continue; if (v != null && typeof v === 'object' && '$in' in (v as any)) { diff --git a/packages/plugins/plugin-sharing/src/boot-backfill.test.ts b/packages/plugins/plugin-sharing/src/boot-backfill.test.ts index 625e29bd5d..9f52dc8d89 100644 --- a/packages/plugins/plugin-sharing/src/boot-backfill.test.ts +++ b/packages/plugins/plugin-sharing/src/boot-backfill.test.ts @@ -26,8 +26,11 @@ function makeEngine() { const ensure = (n: string) => (tables[n] ??= []); function matches(row: Row, f: any): boolean { if (!f || typeof f !== 'object') return true; - if (Array.isArray(f.$or)) return f.$or.some((x: any) => matches(row, x)); - if (Array.isArray(f.$and)) return f.$and.every((x: any) => matches(row, x)); + // `$or` / `$and` are conjoined WITH their sibling keys, the way a real + // driver ANDs them — a short-circuiting `return` here would discard every + // sibling equality key in the same object. See #7620. + if (Array.isArray(f.$or) && !f.$or.some((x: any) => matches(row, x))) return false; + if (Array.isArray(f.$and) && !f.$and.every((x: any) => matches(row, x))) return false; for (const [k, v] of Object.entries(f)) { if (k === '$or' || k === '$and') continue; const rv = row[k]; diff --git a/packages/plugins/plugin-sharing/src/bulk-recompute.test.ts b/packages/plugins/plugin-sharing/src/bulk-recompute.test.ts index 4cbe42ee7f..0c4a9064aa 100644 --- a/packages/plugins/plugin-sharing/src/bulk-recompute.test.ts +++ b/packages/plugins/plugin-sharing/src/bulk-recompute.test.ts @@ -61,8 +61,11 @@ function makeEngine() { function matches(row: Row, f: any): boolean { if (!f || typeof f !== 'object') return true; - if (Array.isArray(f.$or)) return f.$or.some((x: any) => matches(row, x)); - if (Array.isArray(f.$and)) return f.$and.every((x: any) => matches(row, x)); + // `$or` / `$and` are conjoined WITH their sibling keys, the way a real + // driver ANDs them — a short-circuiting `return` here would discard every + // sibling equality key in the same object. See #7620. + if (Array.isArray(f.$or) && !f.$or.some((x: any) => matches(row, x))) return false; + if (Array.isArray(f.$and) && !f.$and.every((x: any) => matches(row, x))) return false; for (const [k, v] of Object.entries(f)) { if (k === '$or' || k === '$and') continue; const rv = row[k]; diff --git a/packages/plugins/plugin-sharing/src/record-share-cascade.test.ts b/packages/plugins/plugin-sharing/src/record-share-cascade.test.ts index 08fe60c8f4..62b4dc1af3 100644 --- a/packages/plugins/plugin-sharing/src/record-share-cascade.test.ts +++ b/packages/plugins/plugin-sharing/src/record-share-cascade.test.ts @@ -62,8 +62,11 @@ function makeEngine() { function matches(row: Row, f: any): boolean { if (!f || typeof f !== 'object') return true; - if (Array.isArray(f.$or)) return f.$or.some((x: any) => matches(row, x)); - if (Array.isArray(f.$and)) return f.$and.every((x: any) => matches(row, x)); + // `$or` / `$and` are conjoined WITH their sibling keys, the way a real + // driver ANDs them — a short-circuiting `return` here would discard every + // sibling equality key in the same object. See #7620. + if (Array.isArray(f.$or) && !f.$or.some((x: any) => matches(row, x))) return false; + if (Array.isArray(f.$and) && !f.$and.every((x: any) => matches(row, x))) return false; for (const [k, v] of Object.entries(f)) { if (k === '$or' || k === '$and') continue; const rv = row[k]; diff --git a/packages/plugins/plugin-sharing/src/sharing-service.test.ts b/packages/plugins/plugin-sharing/src/sharing-service.test.ts index 098bfd46c5..554574764a 100644 --- a/packages/plugins/plugin-sharing/src/sharing-service.test.ts +++ b/packages/plugins/plugin-sharing/src/sharing-service.test.ts @@ -20,11 +20,14 @@ function makeFakeEngine(schemas: Record) { function matches(row: FakeRow, filter: any): boolean { if (!filter || typeof filter !== 'object') return true; - if (filter.$or && Array.isArray(filter.$or)) { - return filter.$or.some((f: any) => matches(row, f)); + // `$or` / `$and` are conjoined WITH their sibling keys, the way a real + // driver ANDs them — a short-circuiting `return` here would discard every + // sibling equality key in the same object. See #7620. + if (filter.$or && Array.isArray(filter.$or) && !filter.$or.some((f: any) => matches(row, f))) { + return false; } - if (filter.$and && Array.isArray(filter.$and)) { - return filter.$and.every((f: any) => matches(row, f)); + if (filter.$and && Array.isArray(filter.$and) && !filter.$and.every((f: any) => matches(row, f))) { + return false; } for (const [k, v] of Object.entries(filter)) { if (k === '$or' || k === '$and') continue; diff --git a/packages/plugins/plugin-sharing/src/system-write-skip-notice.test.ts b/packages/plugins/plugin-sharing/src/system-write-skip-notice.test.ts index b1eb492aac..7592770b2b 100644 --- a/packages/plugins/plugin-sharing/src/system-write-skip-notice.test.ts +++ b/packages/plugins/plugin-sharing/src/system-write-skip-notice.test.ts @@ -60,8 +60,11 @@ function makeEngine() { function matches(row: Row, f: any): boolean { if (!f || typeof f !== 'object') return true; - if (Array.isArray(f.$or)) return f.$or.some((x: any) => matches(row, x)); - if (Array.isArray(f.$and)) return f.$and.every((x: any) => matches(row, x)); + // `$or` / `$and` are conjoined WITH their sibling keys, the way a real + // driver ANDs them — a short-circuiting `return` here would discard every + // sibling equality key in the same object. See #7620. + if (Array.isArray(f.$or) && !f.$or.some((x: any) => matches(row, x))) return false; + if (Array.isArray(f.$and) && !f.$and.every((x: any) => matches(row, x))) return false; for (const [k, v] of Object.entries(f)) { if (k === '$or' || k === '$and') continue; const rv = row[k]; diff --git a/packages/runtime/src/domains/share-links-enforcement-context.test.ts b/packages/runtime/src/domains/share-links-enforcement-context.test.ts index d63a85703d..a01993b17b 100644 --- a/packages/runtime/src/domains/share-links-enforcement-context.test.ts +++ b/packages/runtime/src/domains/share-links-enforcement-context.test.ts @@ -146,8 +146,11 @@ const PERMISSION_SETS = [ACCT_MEMBER, EAST_VIEWER]; function matches(row: any, filter: any): boolean { if (!filter || typeof filter !== 'object') return true; - if (Array.isArray(filter.$or)) return filter.$or.some((f: any) => matches(row, f)); - if (Array.isArray(filter.$and)) return filter.$and.every((f: any) => matches(row, f)); + // `$or` / `$and` are conjoined WITH their sibling keys, the way a real + // driver ANDs them — a short-circuiting `return` here would discard every + // sibling equality key in the same object. See #7620. + if (Array.isArray(filter.$or) && !filter.$or.some((f: any) => matches(row, f))) return false; + if (Array.isArray(filter.$and) && !filter.$and.every((f: any) => matches(row, f))) return false; return Object.entries(filter).every(([k, v]) => { if (k === '$or' || k === '$and') return true; if (v != null && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(row[k]);