@@ -6,6 +6,77 @@ import type { AttachmentLifecycleEngine, AttachmentReadMiddlewareCtx } from './a
66
77const silentLogger = ( ) => ( { info : vi . fn ( ) , warn : vi . fn ( ) , debug : vi . fn ( ) } ) ;
88
9+ /**
10+ * Filter Protocol evaluator for the fake engine below — real `$and` / `$or` /
11+ * `$not` semantics, not a shape check.
12+ *
13+ * Mirrors `driver-memory`'s `memory-matcher.ts` and `formula`'s
14+ * `matches-filter.ts`: **every key inside one filter object ANDs**, a `$or`
15+ * array ORs its branches, and a branch's own contents still AND.
16+ *
17+ * This used to understand neither logical operators nor anything but `$in`,
18+ * so these tests could only assert the *shape* of the filter the middleware
19+ * emits, never the rows that filter selects. That is a poor bargain for a
20+ * predicate whose whole job is narrowing: `computeParentVisibilityFilter`
21+ * pairs a discriminator with an id list per branch, and any bug that widens
22+ * `$or` — the class #3774 fixed in the SQL compiler — turns that pairing into
23+ * something looser while leaving every shape assertion green. A fake that
24+ * silently under-implements the protocol cannot see a widening bug, so this
25+ * one implements it for real and **throws** on anything it does not, rather
26+ * than quietly matching.
27+ *
28+ * Values compare as strings on purpose: `computeParentVisibilityFilter`
29+ * stringifies every `parent_id` when it builds the `$in` list, so a numeric
30+ * row value still has to match its stringified filter value.
31+ */
32+ function matchWhere ( row : any , where : any ) : boolean {
33+ if ( where === null || where === undefined ) return true ;
34+ if ( typeof where !== 'object' || Array . isArray ( where ) ) {
35+ throw new Error ( `harness matcher: a filter must be an object, got ${ JSON . stringify ( where ) } ` ) ;
36+ }
37+ // A filter object is the AND of every one of its entries.
38+ return Object . entries ( where ) . every ( ( [ key , val ] ) => {
39+ if ( key === '$and' ) {
40+ if ( ! Array . isArray ( val ) ) throw new Error ( 'harness matcher: $and expects an array' ) ;
41+ return val . every ( ( branch ) => matchWhere ( row , branch ) ) ;
42+ }
43+ if ( key === '$or' ) {
44+ if ( ! Array . isArray ( val ) ) throw new Error ( 'harness matcher: $or expects an array' ) ;
45+ // Each branch is evaluated whole — the keys inside it still AND.
46+ return val . some ( ( branch ) => matchWhere ( row , branch ) ) ;
47+ }
48+ if ( key === '$not' ) return ! matchWhere ( row , val ) ;
49+ if ( key . startsWith ( '$' ) ) {
50+ throw new Error ( `harness matcher: unsupported logical operator ${ key } ` ) ;
51+ }
52+ return matchField ( row [ key ] , val ) ;
53+ } ) ;
54+ }
55+
56+ /** One `field: <spec>` entry. Multiple operators on the same field AND together. */
57+ function matchField ( actual : any , spec : any ) : boolean {
58+ if ( spec === null || spec === undefined ) return actual === null || actual === undefined ;
59+ if ( Array . isArray ( spec ) ) {
60+ throw new Error ( 'harness matcher: a bare array is not a field spec — use { $in: [...] }' ) ;
61+ }
62+ if ( typeof spec !== 'object' ) return String ( actual ) === String ( spec ) ;
63+ return Object . entries ( spec ) . every ( ( [ op , target ] ) => {
64+ switch ( op ) {
65+ case '$eq' :
66+ return String ( actual ) === String ( target ) ;
67+ case '$ne' :
68+ return String ( actual ) !== String ( target ) ;
69+ case '$in' :
70+ return ( target as unknown [ ] ) . map ( String ) . includes ( String ( actual ) ) ;
71+ case '$nin' :
72+ return ! ( target as unknown [ ] ) . map ( String ) . includes ( String ( actual ) ) ;
73+ default :
74+ // Loud on purpose — an ignored operator is how the original blind spot happened.
75+ throw new Error ( `harness matcher: unsupported operator ${ op } — implement it, don't let it match silently` ) ;
76+ }
77+ } ) ;
78+ }
79+
980/**
1081 * Fake engine modelling: a sys_attachment table (system pre-scan) + a
1182 * per-parent-object visibility map keyed by userId. A parent find under a
@@ -19,16 +90,6 @@ function install(opts: {
1990 let mw ! : ( ctx : AttachmentReadMiddlewareCtx , next : ( ) => Promise < void > ) => Promise < void > ;
2091 const calls = { parentFinds : [ ] as Array < { object : string ; ids : string [ ] ; userId ?: string } > } ;
2192
22- const matchWhere = ( row : any , where : any ) : boolean => {
23- if ( ! where || typeof where !== 'object' ) return true ;
24- return Object . entries ( where ) . every ( ( [ k , v ] ) => {
25- if ( v && typeof v === 'object' && Array . isArray ( ( v as any ) . $in ) ) {
26- return ( v as any ) . $in . map ( String ) . includes ( String ( row [ k ] ) ) ;
27- }
28- return String ( row [ k ] ) === String ( v ) ;
29- } ) ;
30- } ;
31-
3293 const engine : AttachmentLifecycleEngine = {
3394 registerHook : ( ) => { } ,
3495 registerMiddleware : ( fn ) => {
@@ -51,7 +112,18 @@ function install(opts: {
51112 update : async ( ) => ( { } ) ,
52113 } ;
53114 installAttachmentReadVisibility ( engine , silentLogger ( ) ) ;
54- return { mw, calls } ;
115+ return {
116+ mw,
117+ calls,
118+ /**
119+ * Ids the given `where` actually selects from the fixture — i.e. what a
120+ * spec-conformant driver would return once the middleware has narrowed
121+ * the query. Asserting on this (not just on the filter's shape) is what
122+ * makes a widened scope visible.
123+ */
124+ selectIds : ( where : unknown ) : string [ ] =>
125+ opts . attachments . filter ( ( r ) => matchWhere ( r , where ) ) . map ( ( r ) => String ( r . id ) ) ,
126+ } ;
55127}
56128
57129/** Drive a read op through the middleware and return the resulting where. */
@@ -111,6 +183,34 @@ describe('installAttachmentReadVisibility', () => {
111183 } ) ;
112184 } ) ;
113185
186+ it ( 'the multi-parent $or admits only rows matching BOTH keys of one branch' , async ( ) => {
187+ // Each parent type has a visible and an invisible record, plus a row whose
188+ // parent_object belongs to one branch while its parent_id appears only in
189+ // the OTHER branch's id list. Nothing but a genuine per-branch AND excludes
190+ // all three, so this pins the pairing itself rather than the filter's shape.
191+ const { mw, selectIds } = install ( {
192+ attachments : [
193+ { id : 'a1' , parent_object : 'att_case' , parent_id : 'c1' } , // visible case
194+ { id : 'a2' , parent_object : 'att_case' , parent_id : 'c2' } , // case u1 cannot read
195+ { id : 'a3' , parent_object : 'att_todo' , parent_id : 't1' } , // visible todo
196+ { id : 'a4' , parent_object : 'att_todo' , parent_id : 't2' } , // todo u1 cannot read
197+ { id : 'a5' , parent_object : 'att_case' , parent_id : 't1' } , // object of branch 1, id of branch 2
198+ ] ,
199+ visible : { att_case : { u1 : [ 'c1' ] } , att_todo : { u1 : [ 't1' ] } } ,
200+ } ) ;
201+ const { where } = await runRead ( mw , { } ) ;
202+
203+ expect ( where ) . toEqual ( {
204+ $or : [
205+ { parent_object : 'att_case' , parent_id : { $in : [ 'c1' ] } } ,
206+ { parent_object : 'att_todo' , parent_id : { $in : [ 't1' ] } } ,
207+ ] ,
208+ } ) ;
209+ // The rows that filter actually returns — a2/a4 (right parent type, wrong
210+ // id) and a5 (right type, an id borrowed from the sibling branch) are out.
211+ expect ( selectIds ( where ) ) . toEqual ( [ 'a1' , 'a3' ] ) ;
212+ } ) ;
213+
114214 it ( 'denies all when no candidate parent is visible' , async ( ) => {
115215 const { mw } = install ( {
116216 attachments : [ { id : 'a3' , parent_object : 'att_secret' , parent_id : 's1' } ] ,
@@ -178,3 +278,100 @@ describe('installAttachmentReadVisibility', () => {
178278 expect ( calls . parentFinds ) . toHaveLength ( 0 ) ;
179279 } ) ;
180280} ) ;
281+
282+ /**
283+ * Who tests the test double? The row assertions above are only worth as much
284+ * as the matcher evaluating them, so it gets the same 2x2 fixture and the same
285+ * expectations as the three production backends:
286+ * `driver-memory/memory-matcher-or-semantics.test.ts`,
287+ * `formula/matches-filter-or-semantics.test.ts`, and
288+ * `driver-sql/sql-driver-or-filter.test.ts`. If this harness ever drifts from
289+ * them, a read scope it declares safe would not be safe in the engine.
290+ */
291+ describe ( 'harness matcher — Filter Protocol $and/$or/$not semantics' , ( ) => {
292+ const ROWS = [
293+ { id : '1' , a : 'x' , b : 'y' , c : 'z' } ,
294+ { id : '2' , a : 'x' , b : 'zz' , c : 'z' } ,
295+ { id : '3' , a : 'qq' , b : 'y' , c : 'z' } ,
296+ { id : '4' , a : 'qq' , b : 'zz' , c : 'z' } ,
297+ ] ;
298+ const ids = ( filter : any ) : string [ ] => ROWS . filter ( ( r ) => matchWhere ( r , filter ) ) . map ( ( r ) => r . id ) ;
299+
300+ it ( 'ANDs the keys of a single multi-key branch' , ( ) => {
301+ expect ( ids ( { $or : [ { a : 'x' , b : 'y' } ] } ) ) . toEqual ( [ '1' ] ) ;
302+ } ) ;
303+
304+ it ( 'ANDs the keys of each branch independently' , ( ) => {
305+ expect ( ids ( { $or : [ { a : 'x' , b : 'y' } , { a : 'qq' , b : 'zz' } ] } ) ) . toEqual ( [ '1' , '4' ] ) ;
306+ } ) ;
307+
308+ it ( 'ANDs operator-object keys within a branch' , ( ) => {
309+ expect ( ids ( { $or : [ { a : { $eq : 'x' } , b : { $ne : 'zz' } } ] } ) ) . toEqual ( [ '1' ] ) ;
310+ } ) ;
311+
312+ it ( 'ANDs keys inside a $or nested in a $or branch' , ( ) => {
313+ expect ( ids ( { $or : [ { $or : [ { a : 'x' , b : 'zz' } ] } ] } ) ) . toEqual ( [ '2' ] ) ;
314+ } ) ;
315+
316+ it ( 'ANDs multiple operators on ONE field within a branch' , ( ) => {
317+ expect ( ids ( { $or : [ { a : { $ne : 'qq' , $eq : 'x' } , b : 'y' } ] } ) ) . toEqual ( [ '1' ] ) ;
318+ } ) ;
319+
320+ it ( 'OR-s a $and branch against a sibling multi-key branch' , ( ) => {
321+ expect ( ids ( { $or : [ { $and : [ { a : 'x' } , { b : 'y' } ] } , { a : 'qq' , b : 'zz' } ] } ) ) . toEqual ( [ '1' , '4' ] ) ;
322+ } ) ;
323+
324+ it ( 'ANDs a $and with a sibling key in the same branch, either order' , ( ) => {
325+ expect ( ids ( { $or : [ { c : 'nope' } , { $and : [ { a : 'qq' } ] , b : 'y' } ] } ) ) . toEqual ( [ '3' ] ) ;
326+ expect ( ids ( { $or : [ { c : 'nope' } , { b : 'y' , $and : [ { a : 'qq' } ] } ] } ) ) . toEqual ( [ '3' ] ) ;
327+ } ) ;
328+
329+ it ( 'keeps single-key $or branches as a plain OR' , ( ) => {
330+ expect ( ids ( { $or : [ { a : 'x' } , { b : 'y' } ] } ) ) . toEqual ( [ '1' , '2' , '3' ] ) ;
331+ } ) ;
332+
333+ it ( 'ANDs a $or with a sibling top-level field key' , ( ) => {
334+ expect ( ids ( { $or : [ { a : 'x' } , { b : 'y' } ] , b : 'zz' } ) ) . toEqual ( [ '2' ] ) ;
335+ } ) ;
336+
337+ it ( 'negates with $not' , ( ) => {
338+ expect ( ids ( { $not : { a : 'x' } } ) ) . toEqual ( [ '3' , '4' ] ) ;
339+ } ) ;
340+
341+ it ( 'tells a correctly paired scope apart from a widened one' , ( ) => {
342+ // The proof this matcher can go red: the same clauses, once AND-ed per
343+ // branch and once flattened to key-level ORs. If both returned the same
344+ // rows, every row assertion in this file would be decorative.
345+ const attachments = [
346+ { id : 'a1' , parent_object : 'att_case' , parent_id : 'c1' } ,
347+ { id : 'a2' , parent_object : 'att_case' , parent_id : 'c2' } ,
348+ { id : 'a3' , parent_object : 'att_todo' , parent_id : 't1' } ,
349+ { id : 'a4' , parent_object : 'att_todo' , parent_id : 't2' } ,
350+ ] ;
351+ const pick = ( f : any ) => attachments . filter ( ( r ) => matchWhere ( r , f ) ) . map ( ( r ) => r . id ) ;
352+
353+ const correct = {
354+ $or : [
355+ { parent_object : 'att_case' , parent_id : { $in : [ 'c1' ] } } ,
356+ { parent_object : 'att_todo' , parent_id : { $in : [ 't1' ] } } ,
357+ ] ,
358+ } ;
359+ const widened = {
360+ $or : [
361+ { parent_object : 'att_case' } ,
362+ { parent_id : { $in : [ 'c1' ] } } ,
363+ { parent_object : 'att_todo' } ,
364+ { parent_id : { $in : [ 't1' ] } } ,
365+ ] ,
366+ } ;
367+ expect ( pick ( correct ) ) . toEqual ( [ 'a1' , 'a3' ] ) ;
368+ expect ( pick ( widened ) ) . toEqual ( [ 'a1' , 'a2' , 'a3' , 'a4' ] ) ; // every row in the tenant
369+ } ) ;
370+
371+ it ( 'throws instead of silently ignoring an operator it does not implement' , ( ) => {
372+ // The original blind spot in one line: an unimplemented operator must not
373+ // quietly evaluate to a match.
374+ expect ( ( ) => ids ( { a : { $gt : 'x' } } ) ) . toThrow ( / u n s u p p o r t e d o p e r a t o r \$ g t / ) ;
375+ expect ( ( ) => ids ( { $nor : [ { a : 'x' } ] } ) ) . toThrow ( / u n s u p p o r t e d l o g i c a l o p e r a t o r \$ n o r / ) ;
376+ } ) ;
377+ } ) ;
0 commit comments