@@ -48,6 +48,7 @@ import type { IHttpServer } from '@objectstack/spec/contracts';
4848
4949import { createDispatcherPlugin } from './dispatcher-plugin.js' ;
5050import { DriverPlugin } from './driver-plugin.js' ;
51+ import { captureExpectedReadRefusals } from './expected-read-refusal-noise.js' ;
5152
5253// One inbox per concern. The mark-read routes MUTATE read-state, so a shared
5354// user would make these suites order-dependent — the unread fixture the gap
@@ -63,18 +64,46 @@ const declaredMarkReadKeys = () => new Set(Object.keys((MarkNotificationsReadRes
6364const declaredMarkAllReadKeys = ( ) => new Set ( Object . keys ( ( MarkAllNotificationsReadResponseSchema as any ) . shape ) ) ;
6465
6566// ═══════════════════════════════════════════════════════════════════════════
66- // [#10380] The authz resolver's expected read failures: WITHHELD from the
67- // shared log, and ASSERTED instead
67+ // [#10380 → #10629 → #13325 ] The authz resolver's expected read failures:
68+ // WITHHELD from the shared log, and ASSERTED instead
6869// ═══════════════════════════════════════════════════════════════════════════
6970//
7071// This fixture provisions the messaging objects and nothing else, so every
7172// request's `resolveUserAuthzGrants` (`core/src/security/resolve-authz-context.ts`)
7273// reads six `sys_*` tables that were never created. `tryFind` swallows each
7374// one by design — the resolver is fail-closed and must always resolve — but on
74- // the way out the driver and the engine each log it. Measured on `origin/main`:
75- // 63 `[sql-driver] DATABASE_ERROR — the backend refused a read on '…'` lines
76- // and 63 matching `ERROR Find operation failed` frames, out of a suite whose
77- // eight tests all PASS.
75+ // the way out the driver and the engine each log it.
76+ //
77+ // ⚠️ [#13273] WHICH ENGINE CHANNEL — and why this file stopped rolling its own
78+ // capture. `ObjectQL.reportFindFailure` now picks the level from the CAUSE: a
79+ // read whose table was never provisioned — i.e. every read this block is
80+ // declared over — is logged at `debug`, carrying a
81+ // `reason: 'table-not-provisioned'` meta and no stack; every other read failure
82+ // keeps `error` with the stack. This file used to carry its own copy of the
83+ // capture and wrapped the engine's `error` channel ONLY, so from that change
84+ // onward its recognition arm could not match a single frame — measured on this
85+ // tree before the migration, the engine's `error` channel was invoked 0 times
86+ // while 63 `Find operation failed` frames arrived on `debug`, every one of
87+ // which satisfied that arm's own predicate. Dead suppression that read as live
88+ // protection, and the file stayed green throughout because everything it
89+ // asserted was fed by the DRIVER channel. It now uses the shared
90+ // `captureExpectedReadRefusals` (#10629), which wraps BOTH channels, so which
91+ // channel a frame arrives on is the ENGINE's classification and never this
92+ // fixture's problem.
93+ //
94+ // Counts RE-MEASURED on this tree (#13325), not transcribed:
95+ //
96+ // pnpm --filter @objectstack/runtime exec vitest run \
97+ // src/notification-schema-conformance.integration.test.ts
98+ //
99+ // → 63 `[sql-driver] DATABASE_ERROR — the backend refused a read on '…'`
100+ // refusals withheld on the DRIVER channel, and 63 matching engine
101+ // `Find operation failed` frames withheld on the `debug` channel, out of a
102+ // suite whose eight tests all PASS. ⛔ Those two totals are PROSE, not a pin
103+ // — they move with the routes this file drives, and re-deriving them means
104+ // reading `noise.totalRefusals()` / `noise.totalEngineFrames()` off a run,
105+ // never copying the numbers forward. What IS asserted is
106+ // `silentChannels()`: every always-read table fired on BOTH channels.
78107//
79108// Turbo interleaves package logs without attribution, so in the `Test Core`
80109// shard log those are indistinguishable from a real failure — they were lifted
@@ -84,10 +113,10 @@ const declaredMarkAllReadKeys = () => new Set(Object.keys((MarkAllNotificationsR
84113// ⛔ Not a mute. A capture that only silences would make this file blind: if
85114// those reads ever started SUCCEEDING (someone provisions the tables) or
86115// stopped happening (the resolver drops a read), the log would go quiet and
87- // nothing would notice. So the sinks below withhold ONLY the expected fault —
88- // each line must name one of the six tables AND carry that same table's
89- // `no such table` reason — and record what they withheld, which `afterAll`
90- // asserts. Everything else reaches the console untouched .
116+ // nothing would notice. So the shared capture withholds ONLY the expected
117+ // fault — each line must name one of the six tables AND carry that same
118+ // table's `no such table` reason — and COUNTS what it withheld, per table and
119+ // per channel, which `afterAll` asserts .
91120
92121/**
93122 * The six tables the resolver reads and this fixture does not provision.
@@ -111,76 +140,6 @@ const ABSENT_AUTHZ_TABLES = [
111140 */
112141const ALWAYS_READ_AUTHZ_TABLES = ABSENT_AUTHZ_TABLES . filter ( ( t ) => t !== 'sys_setting' ) ;
113142
114- /** Installs the two sinks and hands back the tables they withheld. */
115- function captureExpectedAbsentTableNoise ( ) {
116- const withheld = new Set < string > ( ) ;
117- /** Recognised driver refusals not yet consumed by their engine frame. */
118- const pending = new Map < string , number > ( ) ;
119-
120- /** The refusal envelope AND the dialect reason must name the SAME table. */
121- const expectedRefusal = ( line : string ) : string | undefined =>
122- ABSENT_AUTHZ_TABLES . find (
123- ( t ) => line . includes ( `refused a read on '${ t } '` ) && line . includes ( `no such table: ${ t } ` ) ,
124- ) ;
125-
126- return {
127- withheld,
128-
129- /**
130- * The driver's sink. Mirrors the default's `{ warn, error }` shape so
131- * `logDurabilityFailure` still finds an `error` channel.
132- */
133- install ( driver : unknown ) : void {
134- ( driver as { logger : unknown } ) . logger = {
135- warn : ( msg : string , meta ?: unknown ) : void => {
136- const table = expectedRefusal ( String ( msg ) ) ;
137- if ( table ) {
138- withheld . add ( table ) ;
139- pending . set ( table , ( pending . get ( table ) ?? 0 ) + 1 ) ;
140- return ;
141- }
142- console . warn ( msg , meta ?? '' ) ;
143- } ,
144- error : ( msg : string , meta ?: unknown ) : void => console . error ( msg , meta ?? '' ) ,
145- } ;
146- } ,
147-
148- /**
149- * The engine's `error` channel, through a Proxy so every other logger
150- * method stays the engine's own.
151- *
152- * ⛔ Gated on `pending`: an engine frame is withheld only when it sits
153- * directly above a driver refusal this capture already recognised. A
154- * `DATABASE_ERROR` on one of these six tables arising from any OTHER cause
155- * is not recognised by the driver sink, so its frame is not withheld here
156- * either — it reaches the log with both halves intact.
157- */
158- wrapEngine ( engine : unknown ) : void {
159- const base = ( engine as { logger : any } ) . logger ;
160- ( engine as { logger : any } ) . logger = new Proxy ( base , {
161- get : ( target : any , key : string ) =>
162- key === 'error'
163- ? ( msg : string , err ?: unknown , meta ?: unknown ) => {
164- const object = ( meta as { object ?: string } | undefined ) ?. object ;
165- const outstanding = object ? ( pending . get ( object ) ?? 0 ) : 0 ;
166- const detail = String ( ( err as { message ?: string } | undefined ) ?. message ?? '' ) ;
167- if (
168- msg === 'Find operation failed' &&
169- object &&
170- outstanding > 0 &&
171- detail . includes ( `refused to run this query for object '${ object } '` )
172- ) {
173- pending . set ( object , outstanding - 1 ) ;
174- return ;
175- }
176- target . error ( msg , err , meta ) ;
177- }
178- : target [ key ] ,
179- } ) ;
180- } ,
181- } ;
182- }
183-
184143/** Minimal `auth` service — `x-test-user` names the principal, absent = anonymous. */
185144function fakeAuthPlugin ( ) : Plugin {
186145 return {
@@ -205,15 +164,19 @@ describe('[#5792] the notification wire bodies conform to the schemas the catalo
205164 let kernel : ObjectKernel ;
206165 let baseUrl : string ;
207166 let messaging : MessagingService ;
208- /** [#10380] The expected-noise capture, asserted in `afterAll`. */
209- const noise = captureExpectedAbsentTableNoise ( ) ;
167+ /**
168+ * [#10629] The expected-noise capture, asserted in `afterAll`. The SHARED
169+ * one — see the block above for what the per-fixture copy this replaced
170+ * could no longer do.
171+ */
172+ const noise = captureExpectedReadRefusals ( [ ...ABSENT_AUTHZ_TABLES ] ) ;
210173
211174 beforeAll ( async ( ) => {
212175 kernel = new ObjectKernel ( { logger : { level : 'silent' } } ) ;
213176 // [#10380] The driver is named rather than inlined so its logger can be
214177 // scoped before it ever runs a statement.
215178 const driver = new SqliteWasmDriver ( { filename : ':memory:' } ) ;
216- noise . install ( driver ) ;
179+ noise . captureDriver ( driver ) ;
217180 await kernel . use ( new DriverPlugin ( driver ) ) ;
218181 await kernel . use ( new ObjectQLPlugin ( ) ) ;
219182 // Inline delivery so `emit()` materializes the inbox row synchronously.
@@ -225,7 +188,7 @@ describe('[#5792] the notification wire bodies conform to the schemas the catalo
225188
226189 // [#10380] The engine only exists once the kernel has bootstrapped; the
227190 // reads this scopes all happen later, per request.
228- noise . wrapEngine ( kernel . getService < unknown > ( 'objectql' ) ) ;
191+ noise . captureEngine ( kernel . getService < unknown > ( 'objectql' ) ) ;
229192
230193 const httpServer = kernel . getService < IHttpServer > ( 'http.server' ) ;
231194 baseUrl = `http://127.0.0.1:${ httpServer . getPort ! ( ) } ` ;
@@ -255,13 +218,13 @@ describe('[#5792] the notification wire bodies conform to the schemas the catalo
255218 // right repair is to re-derive the list above, NOT to relax this: a
256219 // resolver read that stopped happening is a finding, and a table that
257220 // started resolving means this fixture now provisions it.
258- for ( const table of ALWAYS_READ_AUTHZ_TABLES ) {
259- expect (
260- noise . withheld . has ( table ) ,
261- `the expected ' ${ table } ' read failure was never emitted — resolveUserAuthzGrants ` +
262- 'no longer reads it, or it no longer fails, and this capture has stopped measuring it' ,
263- ) . toBe ( true ) ;
264- }
221+ //
222+ // ⚠️ [#13325] STRICTLY MORE than the per-table `withheld.has(table)` loop
223+ // this replaced, never less: that loop read the DRIVER channel only, which
224+ // is exactly why it kept passing while the engine half of the same capture
225+ // was dead. `silentChannels()` requires BOTH channels to have fired for
226+ // each table, and a silent one NAMES ITSELF in the diff.
227+ expect ( noise . silentChannels ( ALWAYS_READ_AUTHZ_TABLES ) ) . toEqual ( [ ] ) ;
265228 } , 30_000 ) ;
266229
267230 /** Drive one route as `user`, asserting the shared envelope, and hand back `data`. */
0 commit comments