Skip to content

Commit 9e4bad5

Browse files
os-zhuangclaude
andauthored
test(service-storage): give the attachment read-visibility harness real Filter Protocol semantics (#3792)
`attachment-read-visibility.test.ts` faked its engine with a matcher that understood implicit equality and `$in` and nothing else — no `$and`, no `$or`, no `$not`. Every assertion could therefore only check the *shape* of the filter `computeParentVisibilityFilter` emits, never the rows that filter selects. That is a poor bargain for a predicate whose whole job is narrowing: it pairs a discriminator with an id list per branch, and anything that widens `$or` loosens that pairing while leaving shape assertions green. - The matcher now implements the protocol for real, mirroring driver-memory's `memory-matcher.ts` and formula's `matches-filter.ts`: every key in a filter object ANDs, `$or` ORs its branches, a branch's own contents still AND. Unimplemented operators now throw rather than being ignored — a silently under-implemented test double is the failure mode this change exists to close. - New case asserts the ROWS a multi-parent-type scope returns: rows whose discriminator matches a branch but whose id is absent from that branch's id list — including one borrowing the sibling branch's id — are excluded, pinning the per-branch pairing itself. - A conformance block pins the harness against the same 2x2 fixture and expectations as `memory-matcher-or-semantics.test.ts`, `matches-filter-or-semantics.test.ts` and `sql-driver-or-filter.test.ts`, so the four cannot drift apart, plus a case proving the matcher tells a correctly paired scope from a widened one. Test-only; no runtime change. service-storage: 212/212 green. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7457a09 commit 9e4bad5

2 files changed

Lines changed: 238 additions & 11 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
---
3+
4+
test(service-storage): give the attachment read-visibility harness real Filter Protocol semantics
5+
6+
Test-only — releases nothing.
7+
8+
`attachment-read-visibility.test.ts` faked its engine with a matcher that
9+
understood implicit equality and `$in` and nothing else: no `$and`, no `$or`,
10+
no `$not`. Every assertion in the file could therefore only check the *shape*
11+
of the filter `computeParentVisibilityFilter` emits, never the rows that filter
12+
selects — a poor bargain for a predicate whose whole job is narrowing.
13+
14+
Changes:
15+
16+
- The harness matcher now implements the protocol for real, mirroring
17+
`driver-memory/memory-matcher.ts` and `formula/matches-filter.ts`: every key
18+
in a filter object ANDs, `$or` ORs its branches, and a branch's own contents
19+
still AND. Operators it does not implement now **throw** instead of being
20+
ignored — a silently under-implemented test double is the failure mode this
21+
change exists to close.
22+
- A new case asserts the **rows** a multi-parent-type scope returns, not just
23+
its shape: rows whose discriminator matches a branch but whose id is absent
24+
from that branch's id list (including one that borrows the sibling branch's
25+
id) are excluded, so the per-branch pairing itself is pinned.
26+
- A conformance block pins the harness matcher against the same 2x2 fixture and
27+
expectations as `memory-matcher-or-semantics.test.ts`,
28+
`matches-filter-or-semantics.test.ts` and `sql-driver-or-filter.test.ts`, so
29+
the four cannot drift apart, plus a case proving the matcher actually
30+
distinguishes a correctly paired scope from a widened one.

packages/services/service-storage/src/attachment-read-visibility.test.ts

Lines changed: 208 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,77 @@ import type { AttachmentLifecycleEngine, AttachmentReadMiddlewareCtx } from './a
66

77
const 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(/unsupported operator \$gt/);
375+
expect(() => ids({ $nor: [{ a: 'x' }] })).toThrow(/unsupported logical operator \$nor/);
376+
});
377+
});

0 commit comments

Comments
 (0)