From e87c43523ca2543f5b9b00e14c4051e4c8aaf926 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 23:07:53 +0000 Subject: [PATCH 1/2] test(client): provision the authz resolver's tables in the seven refusing fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WIP — the seven `@objectstack/client` sites from #18070 now register the `sys_*` objects `resolveUserAuthzGrants` reads, locally and with only the columns that reading path touches. Claude-Session: https://claude.ai/code/session_01TbSMtGzMrtPwh925wDEZd5 Co-authored-by: Claude --- .../src/auth-get-session-envelope.test.ts | 83 ++++++++++++++ .../src/auth-login-register-envelope.test.ts | 83 ++++++++++++++ .../src/client.batch-transaction.test.ts | 104 ++++++++++++++++++ .../client/src/client.data-prefix.test.ts | 104 ++++++++++++++++++ .../src/client.environment-scoping.test.ts | 104 ++++++++++++++++++ packages/client/src/client.hono.test.ts | 104 ++++++++++++++++++ .../client/src/client.metadata-prefix.test.ts | 104 ++++++++++++++++++ 7 files changed, 686 insertions(+) diff --git a/packages/client/src/auth-get-session-envelope.test.ts b/packages/client/src/auth-get-session-envelope.test.ts index 50d1295690..216bbcf309 100644 --- a/packages/client/src/auth-get-session-envelope.test.ts +++ b/packages/client/src/auth-get-session-envelope.test.ts @@ -53,6 +53,7 @@ import { AuthManager } from '@objectstack/plugin-auth'; import * as identityObjects from '@objectstack/platform-objects/identity'; import { BaseResponseSchema, SessionResponseSchema, SessionSchema } from '@objectstack/spec/api'; import { ObjectStackClient } from './index'; +import type { ServiceObject } from '@objectstack/spec/data'; const SECRET = 'test-secret-at-least-32-chars-long!!'; const ORIGIN = 'http://localhost:3000'; @@ -76,6 +77,83 @@ const IDENTITY_OBJECTS = Object.values( typeof (o as { fields?: unknown }).fields === 'object', ); +/** + * ⚠️ [#18070] The authz objects every authenticated request in this file + * makes core's `resolveUserAuthzGrants` + * (`core/src/security/resolve-authz-context.ts`) read. They belong to + * `@objectstack/plugin-security` and are spelled LOCALLY here, carrying only + * the columns that reading path touches, so this suite adds no dependency edge + * onto that package — the shape PR #17982 and PR #18067 landed for the same + * defect. + * + * Without them the driver REFUSED every one of those reads. Measured on + * `fb29f62ce`, classified by the `(table, filter, limit)` triple of the eight + * reads the resolver issues: **21** resolver-class `refused a read on` driver + * lines in this file, 7 per table across 3. `tryFind` classifies a missing + * table as "not provisioned" and answers `[]`, so nothing went red: every + * assertion below passed over grant reads that never happened — a green this + * suite had not earned, and one it could not lose if grant resolution broke. + * + * ⛔ Registering the tables is what makes those reads SUCCEED. The count must + * ⛔ not fall by silencing, filtering or re-levelling the driver line. + * + * ⭐ Only THREE tables, and that is the measurement talking: this fixture + * already provisions `sys_user` and `sys_member` through `IDENTITY_OBJECTS` + * above, so those two reads always succeeded here. The three below are the + * `@objectstack/plugin-security` side, which nothing in this file provisioned. + * + * Columns, and why each is here — every other column of the real objects is + * deliberately absent, because no read on this path touches it. `id` is not + * declared anywhere below: the registry supplies the primary key itself. + * `sys_user_position` user_id (filter), position, organization_id + * `sys_user_permission_set` user_id (filter), permission_set_id, organization_id + * `sys_position` name (filter), active (`isRowActive`), + * organization_id (the driver's tenant scope) + * + * ⛔ `sys_position_permission_set` and `sys_permission_set` are NOT here: the + * resolver reaches them only once a `sys_position` row resolves and a + * permission-set id is collected, and nothing here seeds either — measured, + * neither table appears in this file's refusals, before or after. + */ +const AUTHZ_RESOLVER_OBJECTS: { owner: string; def: ServiceObject }[] = [ + { + owner: '@objectstack/plugin-security', + def: { + name: 'sys_user_position', + label: 'User Position', + fields: { + user_id: { type: 'text', label: 'User' }, + position: { type: 'text', label: 'Position' }, + organization_id: { type: 'text', label: 'Organization' }, + }, + }, + }, + { + owner: '@objectstack/plugin-security', + def: { + name: 'sys_user_permission_set', + label: 'User Permission Set', + fields: { + user_id: { type: 'text', label: 'User' }, + permission_set_id: { type: 'text', label: 'Permission Set' }, + organization_id: { type: 'text', label: 'Organization' }, + }, + }, + }, + { + owner: '@objectstack/plugin-security', + def: { + name: 'sys_position', + label: 'Position', + fields: { + name: { type: 'text', label: 'Name' }, + active: { type: 'boolean', label: 'Active' }, + organization_id: { type: 'text', label: 'Organization' }, + }, + }, + }, +]; + const engines: ObjectQL[] = []; const makeEngine = async (): Promise => { @@ -86,6 +164,11 @@ const makeEngine = async (): Promise => { for (const object of IDENTITY_OBJECTS) { engine.registry.registerObject(object as never, '@objectstack/plugin-auth'); } + // [#18070] The authz resolver's plugin-security-side reads, registered + // before the sync so the driver PROVISIONS them rather than refusing them. + for (const o of AUTHZ_RESOLVER_OBJECTS) { + engine.registry.registerObject(o.def, o.owner); + } await engine.syncSchemas(); return engine; }; diff --git a/packages/client/src/auth-login-register-envelope.test.ts b/packages/client/src/auth-login-register-envelope.test.ts index 49175888ab..c3312dc82e 100644 --- a/packages/client/src/auth-login-register-envelope.test.ts +++ b/packages/client/src/auth-login-register-envelope.test.ts @@ -56,6 +56,7 @@ import { AuthManager } from '@objectstack/plugin-auth'; import * as identityObjects from '@objectstack/platform-objects/identity'; import { BaseResponseSchema, SessionResponseSchema, SessionSchema } from '@objectstack/spec/api'; import { ObjectStackClient } from './index'; +import type { ServiceObject } from '@objectstack/spec/data'; const SECRET = 'test-secret-at-least-32-chars-long!!'; const ORIGIN = 'http://localhost:3000'; @@ -79,6 +80,83 @@ const IDENTITY_OBJECTS = Object.values( typeof (o as { fields?: unknown }).fields === 'object', ); +/** + * ⚠️ [#18070] The authz objects every authenticated request in this file + * makes core's `resolveUserAuthzGrants` + * (`core/src/security/resolve-authz-context.ts`) read. They belong to + * `@objectstack/plugin-security` and are spelled LOCALLY here, carrying only + * the columns that reading path touches, so this suite adds no dependency edge + * onto that package — the shape PR #17982 and PR #18067 landed for the same + * defect. + * + * Without them the driver REFUSED every one of those reads. Measured on + * `fb29f62ce`, classified by the `(table, filter, limit)` triple of the eight + * reads the resolver issues: **6** resolver-class `refused a read on` driver + * lines in this file, 2 per table across 3. `tryFind` classifies a missing + * table as "not provisioned" and answers `[]`, so nothing went red: every + * assertion below passed over grant reads that never happened — a green this + * suite had not earned, and one it could not lose if grant resolution broke. + * + * ⛔ Registering the tables is what makes those reads SUCCEED. The count must + * ⛔ not fall by silencing, filtering or re-levelling the driver line. + * + * ⭐ Only THREE tables, and that is the measurement talking: this fixture + * already provisions `sys_user` and `sys_member` through `IDENTITY_OBJECTS` + * above, so those two reads always succeeded here. The three below are the + * `@objectstack/plugin-security` side, which nothing in this file provisioned. + * + * Columns, and why each is here — every other column of the real objects is + * deliberately absent, because no read on this path touches it. `id` is not + * declared anywhere below: the registry supplies the primary key itself. + * `sys_user_position` user_id (filter), position, organization_id + * `sys_user_permission_set` user_id (filter), permission_set_id, organization_id + * `sys_position` name (filter), active (`isRowActive`), + * organization_id (the driver's tenant scope) + * + * ⛔ `sys_position_permission_set` and `sys_permission_set` are NOT here: the + * resolver reaches them only once a `sys_position` row resolves and a + * permission-set id is collected, and nothing here seeds either — measured, + * neither table appears in this file's refusals, before or after. + */ +const AUTHZ_RESOLVER_OBJECTS: { owner: string; def: ServiceObject }[] = [ + { + owner: '@objectstack/plugin-security', + def: { + name: 'sys_user_position', + label: 'User Position', + fields: { + user_id: { type: 'text', label: 'User' }, + position: { type: 'text', label: 'Position' }, + organization_id: { type: 'text', label: 'Organization' }, + }, + }, + }, + { + owner: '@objectstack/plugin-security', + def: { + name: 'sys_user_permission_set', + label: 'User Permission Set', + fields: { + user_id: { type: 'text', label: 'User' }, + permission_set_id: { type: 'text', label: 'Permission Set' }, + organization_id: { type: 'text', label: 'Organization' }, + }, + }, + }, + { + owner: '@objectstack/plugin-security', + def: { + name: 'sys_position', + label: 'Position', + fields: { + name: { type: 'text', label: 'Name' }, + active: { type: 'boolean', label: 'Active' }, + organization_id: { type: 'text', label: 'Organization' }, + }, + }, + }, +]; + const engines: ObjectQL[] = []; const makeEngine = async (): Promise => { @@ -89,6 +167,11 @@ const makeEngine = async (): Promise => { for (const object of IDENTITY_OBJECTS) { engine.registry.registerObject(object as never, '@objectstack/plugin-auth'); } + // [#18070] The authz resolver's plugin-security-side reads, registered + // before the sync so the driver PROVISIONS them rather than refusing them. + for (const o of AUTHZ_RESOLVER_OBJECTS) { + engine.registry.registerObject(o.def, o.owner); + } await engine.syncSchemas(); return engine; }; diff --git a/packages/client/src/client.batch-transaction.test.ts b/packages/client/src/client.batch-transaction.test.ts index 91a7ab8c50..e72ccbb8cd 100644 --- a/packages/client/src/client.batch-transaction.test.ts +++ b/packages/client/src/client.batch-transaction.test.ts @@ -34,6 +34,104 @@ import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; import { createRestApiPlugin } from '@objectstack/runtime'; import { ObjectStackClient } from './index'; import type { IHttpServer } from '@objectstack/spec/contracts'; +import type { ServiceObject } from '@objectstack/spec/data'; + +/** + * ⚠️ [#18070] The authz objects every authenticated request in this file + * makes core's `resolveUserAuthzGrants` + * (`core/src/security/resolve-authz-context.ts`) read. They belong to + * `@objectstack/plugin-auth` / `@objectstack/plugin-security` and are spelled + * LOCALLY here, carrying only the columns that reading path touches, so this + * suite adds no dependency edge onto either package — the shape PR #17982 and + * PR #18067 landed for the same defect. + * + * Without them the driver REFUSED every one of those reads. Measured on + * `fb29f62ce`, classified by the `(table, filter, limit)` triple of the eight + * reads the resolver issues: **25** resolver-class `refused a read on` driver + * lines in this file, 5 per table across 5. `tryFind` classifies a missing + * table as "not provisioned" and answers `[]`, so nothing went red: every + * assertion below passed over grant reads that never happened — a green this + * suite had not earned, and one it could not lose if grant resolution broke. + * + * ⛔ Registering the tables is what makes those reads SUCCEED. The count must + * ⛔ not fall by silencing, filtering or re-levelling the driver line. + * + * Columns, and why each is here — every other column of the real objects is + * deliberately absent, because no read on this path touches it. `id` is not + * declared anywhere below: the registry supplies the primary key itself, and + * it is what `sys_user`'s `id` filter reads. + * `sys_user` email (the `current_user.email` owner-RLS fallback) + * `sys_member` user_id / organization_id (both filters), role + * `sys_user_position` user_id (filter), position, organization_id + * `sys_user_permission_set` user_id (filter), permission_set_id, organization_id + * `sys_position` name (filter), active (`isRowActive`), + * organization_id (the driver's tenant scope) + * + * ⛔ `sys_position_permission_set` and `sys_permission_set` are NOT here: the + * resolver reaches them only once a `sys_position` row resolves and a + * permission-set id is collected, and nothing here seeds either — measured, + * neither table appears in this file's refusals, before or after. + */ +const AUTHZ_RESOLVER_OBJECTS: { owner: string; def: ServiceObject }[] = [ + { + owner: '@objectstack/plugin-auth', + def: { + name: 'sys_user', + label: 'User', + fields: { + email: { type: 'text', label: 'Email' }, + }, + }, + }, + { + owner: '@objectstack/plugin-auth', + def: { + name: 'sys_member', + label: 'Member', + fields: { + user_id: { type: 'text', label: 'User' }, + organization_id: { type: 'text', label: 'Organization' }, + role: { type: 'text', label: 'Role' }, + }, + }, + }, + { + owner: '@objectstack/plugin-security', + def: { + name: 'sys_user_position', + label: 'User Position', + fields: { + user_id: { type: 'text', label: 'User' }, + position: { type: 'text', label: 'Position' }, + organization_id: { type: 'text', label: 'Organization' }, + }, + }, + }, + { + owner: '@objectstack/plugin-security', + def: { + name: 'sys_user_permission_set', + label: 'User Permission Set', + fields: { + user_id: { type: 'text', label: 'User' }, + permission_set_id: { type: 'text', label: 'Permission Set' }, + organization_id: { type: 'text', label: 'Organization' }, + }, + }, + }, + { + owner: '@objectstack/plugin-security', + def: { + name: 'sys_position', + label: 'Position', + fields: { + name: { type: 'text', label: 'Name' }, + active: { type: 'boolean', label: 'Active' }, + organization_id: { type: 'text', label: 'Organization' }, + }, + }, + }, +]; describe('data.batchTransaction (live Hono, #1604)', () => { let baseUrl: string; @@ -106,6 +204,12 @@ describe('data.batchTransaction (live Hono, #1604)', () => { // write fails with `no such table`. await ql.syncObjectSchema('project'); await ql.syncObjectSchema('task'); + // [#18070] The authz resolver's own reads, registered and synced so the + // driver PROVISIONS them rather than refusing them. + for (const o of AUTHZ_RESOLVER_OBJECTS) { + ql.registerObject(o.def, o.owner); + await ql.syncObjectSchema(o.def.name); + } const httpServer = kernel.getService('http.server'); baseUrl = `http://localhost:${httpServer.getPort!()}`; diff --git a/packages/client/src/client.data-prefix.test.ts b/packages/client/src/client.data-prefix.test.ts index af4a75b951..c8a4755c1b 100644 --- a/packages/client/src/client.data-prefix.test.ts +++ b/packages/client/src/client.data-prefix.test.ts @@ -47,6 +47,7 @@ import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; import { createRestApiPlugin } from '@objectstack/runtime'; import { ObjectStackClient } from './index'; import type { IHttpServer } from '@objectstack/spec/contracts'; +import type { ServiceObject } from '@objectstack/spec/data'; const ENV_ID = 'proj-alpha'; const CUSTOM_PREFIX = '/objects'; @@ -56,6 +57,103 @@ interface Fixture { kernel: LiteKernel; } +/** + * ⚠️ [#18070] The authz objects every authenticated request in this file + * makes core's `resolveUserAuthzGrants` + * (`core/src/security/resolve-authz-context.ts`) read. They belong to + * `@objectstack/plugin-auth` / `@objectstack/plugin-security` and are spelled + * LOCALLY here, carrying only the columns that reading path touches, so this + * suite adds no dependency edge onto either package — the shape PR #17982 and + * PR #18067 landed for the same defect. + * + * Without them the driver REFUSED every one of those reads. Measured on + * `fb29f62ce`, classified by the `(table, filter, limit)` triple of the eight + * reads the resolver issues: **25** resolver-class `refused a read on` driver + * lines in this file, 5 per table across 5. `tryFind` classifies a missing + * table as "not provisioned" and answers `[]`, so nothing went red: every + * assertion below passed over grant reads that never happened — a green this + * suite had not earned, and one it could not lose if grant resolution broke. + * + * ⛔ Registering the tables is what makes those reads SUCCEED. The count must + * ⛔ not fall by silencing, filtering or re-levelling the driver line. + * + * Columns, and why each is here — every other column of the real objects is + * deliberately absent, because no read on this path touches it. `id` is not + * declared anywhere below: the registry supplies the primary key itself, and + * it is what `sys_user`'s `id` filter reads. + * `sys_user` email (the `current_user.email` owner-RLS fallback) + * `sys_member` user_id / organization_id (both filters), role + * `sys_user_position` user_id (filter), position, organization_id + * `sys_user_permission_set` user_id (filter), permission_set_id, organization_id + * `sys_position` name (filter), active (`isRowActive`), + * organization_id (the driver's tenant scope) + * + * ⛔ `sys_position_permission_set` and `sys_permission_set` are NOT here: the + * resolver reaches them only once a `sys_position` row resolves and a + * permission-set id is collected, and nothing here seeds either — measured, + * neither table appears in this file's refusals, before or after. + */ +const AUTHZ_RESOLVER_OBJECTS: { owner: string; def: ServiceObject }[] = [ + { + owner: '@objectstack/plugin-auth', + def: { + name: 'sys_user', + label: 'User', + fields: { + email: { type: 'text', label: 'Email' }, + }, + }, + }, + { + owner: '@objectstack/plugin-auth', + def: { + name: 'sys_member', + label: 'Member', + fields: { + user_id: { type: 'text', label: 'User' }, + organization_id: { type: 'text', label: 'Organization' }, + role: { type: 'text', label: 'Role' }, + }, + }, + }, + { + owner: '@objectstack/plugin-security', + def: { + name: 'sys_user_position', + label: 'User Position', + fields: { + user_id: { type: 'text', label: 'User' }, + position: { type: 'text', label: 'Position' }, + organization_id: { type: 'text', label: 'Organization' }, + }, + }, + }, + { + owner: '@objectstack/plugin-security', + def: { + name: 'sys_user_permission_set', + label: 'User Permission Set', + fields: { + user_id: { type: 'text', label: 'User' }, + permission_set_id: { type: 'text', label: 'Permission Set' }, + organization_id: { type: 'text', label: 'Organization' }, + }, + }, + }, + { + owner: '@objectstack/plugin-security', + def: { + name: 'sys_position', + label: 'Position', + fields: { + name: { type: 'text', label: 'Name' }, + active: { type: 'boolean', label: 'Active' }, + organization_id: { type: 'text', label: 'Organization' }, + }, + }, + }, +]; + /** * One boot recipe, two prefixes — so the non-default case and the control * differ in exactly the key under test and nothing else. @@ -105,6 +203,12 @@ async function bootServer(dataPrefix?: string): Promise { }); // Registered after bootstrap, so nothing has issued the DDL yet (#4065). await ql.syncObjectSchema('task'); + // [#18070] The authz resolver's own reads, registered and synced so the + // driver PROVISIONS them rather than refusing them. + for (const o of AUTHZ_RESOLVER_OBJECTS) { + ql.registerObject(o.def, o.owner); + await ql.syncObjectSchema(o.def.name); + } const httpServer = kernel.getService('http.server'); const port = httpServer.getPort!(); diff --git a/packages/client/src/client.environment-scoping.test.ts b/packages/client/src/client.environment-scoping.test.ts index 7069f913b6..2aa383b087 100644 --- a/packages/client/src/client.environment-scoping.test.ts +++ b/packages/client/src/client.environment-scoping.test.ts @@ -21,6 +21,104 @@ import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; import { createRestApiPlugin } from '@objectstack/runtime'; import { ObjectStackClient } from './index'; import type { IHttpServer } from '@objectstack/spec/contracts'; +import type { ServiceObject } from '@objectstack/spec/data'; + +/** + * ⚠️ [#18070] The authz objects every authenticated request in this file + * makes core's `resolveUserAuthzGrants` + * (`core/src/security/resolve-authz-context.ts`) read. They belong to + * `@objectstack/plugin-auth` / `@objectstack/plugin-security` and are spelled + * LOCALLY here, carrying only the columns that reading path touches, so this + * suite adds no dependency edge onto either package — the shape PR #17982 and + * PR #18067 landed for the same defect. + * + * Without them the driver REFUSED every one of those reads. Measured on + * `fb29f62ce`, classified by the `(table, filter, limit)` triple of the eight + * reads the resolver issues: **20** resolver-class `refused a read on` driver + * lines in this file, 4 per table across 5. `tryFind` classifies a missing + * table as "not provisioned" and answers `[]`, so nothing went red: every + * assertion below passed over grant reads that never happened — a green this + * suite had not earned, and one it could not lose if grant resolution broke. + * + * ⛔ Registering the tables is what makes those reads SUCCEED. The count must + * ⛔ not fall by silencing, filtering or re-levelling the driver line. + * + * Columns, and why each is here — every other column of the real objects is + * deliberately absent, because no read on this path touches it. `id` is not + * declared anywhere below: the registry supplies the primary key itself, and + * it is what `sys_user`'s `id` filter reads. + * `sys_user` email (the `current_user.email` owner-RLS fallback) + * `sys_member` user_id / organization_id (both filters), role + * `sys_user_position` user_id (filter), position, organization_id + * `sys_user_permission_set` user_id (filter), permission_set_id, organization_id + * `sys_position` name (filter), active (`isRowActive`), + * organization_id (the driver's tenant scope) + * + * ⛔ `sys_position_permission_set` and `sys_permission_set` are NOT here: the + * resolver reaches them only once a `sys_position` row resolves and a + * permission-set id is collected, and nothing here seeds either — measured, + * neither table appears in this file's refusals, before or after. + */ +const AUTHZ_RESOLVER_OBJECTS: { owner: string; def: ServiceObject }[] = [ + { + owner: '@objectstack/plugin-auth', + def: { + name: 'sys_user', + label: 'User', + fields: { + email: { type: 'text', label: 'Email' }, + }, + }, + }, + { + owner: '@objectstack/plugin-auth', + def: { + name: 'sys_member', + label: 'Member', + fields: { + user_id: { type: 'text', label: 'User' }, + organization_id: { type: 'text', label: 'Organization' }, + role: { type: 'text', label: 'Role' }, + }, + }, + }, + { + owner: '@objectstack/plugin-security', + def: { + name: 'sys_user_position', + label: 'User Position', + fields: { + user_id: { type: 'text', label: 'User' }, + position: { type: 'text', label: 'Position' }, + organization_id: { type: 'text', label: 'Organization' }, + }, + }, + }, + { + owner: '@objectstack/plugin-security', + def: { + name: 'sys_user_permission_set', + label: 'User Permission Set', + fields: { + user_id: { type: 'text', label: 'User' }, + permission_set_id: { type: 'text', label: 'Permission Set' }, + organization_id: { type: 'text', label: 'Organization' }, + }, + }, + }, + { + owner: '@objectstack/plugin-security', + def: { + name: 'sys_position', + label: 'Position', + fields: { + name: { type: 'text', label: 'Name' }, + active: { type: 'boolean', label: 'Active' }, + organization_id: { type: 'text', label: 'Organization' }, + }, + }, + }, +]; describe('Project-scoped REST routing (live Hono)', () => { let baseUrl: string; @@ -82,6 +180,12 @@ describe('Project-scoped REST routing (live Hono)', () => { // #4065 created a table on first touch and hid that; on SQL the first // write fails with `no such table`. await ql.syncObjectSchema('task'); + // [#18070] The authz resolver's own reads, registered and synced so the + // driver PROVISIONS them rather than refusing them. + for (const o of AUTHZ_RESOLVER_OBJECTS) { + ql.registerObject(o.def, o.owner); + await ql.syncObjectSchema(o.def.name); + } const httpServer = kernel.getService('http.server'); const port = httpServer.getPort!(); diff --git a/packages/client/src/client.hono.test.ts b/packages/client/src/client.hono.test.ts index 5731a3e54e..48302233de 100644 --- a/packages/client/src/client.hono.test.ts +++ b/packages/client/src/client.hono.test.ts @@ -6,6 +6,104 @@ import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; import { createRestApiPlugin } from '@objectstack/runtime'; import { ObjectStackClient } from './index'; import type { IHttpServer } from '@objectstack/spec/contracts'; +import type { ServiceObject } from '@objectstack/spec/data'; + +/** + * ⚠️ [#18070] The authz objects every authenticated request in this file + * makes core's `resolveUserAuthzGrants` + * (`core/src/security/resolve-authz-context.ts`) read. They belong to + * `@objectstack/plugin-auth` / `@objectstack/plugin-security` and are spelled + * LOCALLY here, carrying only the columns that reading path touches, so this + * suite adds no dependency edge onto either package — the shape PR #17982 and + * PR #18067 landed for the same defect. + * + * Without them the driver REFUSED every one of those reads. Measured on + * `fb29f62ce`, classified by the `(table, filter, limit)` triple of the eight + * reads the resolver issues: **35** resolver-class `refused a read on` driver + * lines in this file, 7 per table across 5. `tryFind` classifies a missing + * table as "not provisioned" and answers `[]`, so nothing went red: every + * assertion below passed over grant reads that never happened — a green this + * suite had not earned, and one it could not lose if grant resolution broke. + * + * ⛔ Registering the tables is what makes those reads SUCCEED. The count must + * ⛔ not fall by silencing, filtering or re-levelling the driver line. + * + * Columns, and why each is here — every other column of the real objects is + * deliberately absent, because no read on this path touches it. `id` is not + * declared anywhere below: the registry supplies the primary key itself, and + * it is what `sys_user`'s `id` filter reads. + * `sys_user` email (the `current_user.email` owner-RLS fallback) + * `sys_member` user_id / organization_id (both filters), role + * `sys_user_position` user_id (filter), position, organization_id + * `sys_user_permission_set` user_id (filter), permission_set_id, organization_id + * `sys_position` name (filter), active (`isRowActive`), + * organization_id (the driver's tenant scope) + * + * ⛔ `sys_position_permission_set` and `sys_permission_set` are NOT here: the + * resolver reaches them only once a `sys_position` row resolves and a + * permission-set id is collected, and nothing here seeds either — measured, + * neither table appears in this file's refusals, before or after. + */ +const AUTHZ_RESOLVER_OBJECTS: { owner: string; def: ServiceObject }[] = [ + { + owner: '@objectstack/plugin-auth', + def: { + name: 'sys_user', + label: 'User', + fields: { + email: { type: 'text', label: 'Email' }, + }, + }, + }, + { + owner: '@objectstack/plugin-auth', + def: { + name: 'sys_member', + label: 'Member', + fields: { + user_id: { type: 'text', label: 'User' }, + organization_id: { type: 'text', label: 'Organization' }, + role: { type: 'text', label: 'Role' }, + }, + }, + }, + { + owner: '@objectstack/plugin-security', + def: { + name: 'sys_user_position', + label: 'User Position', + fields: { + user_id: { type: 'text', label: 'User' }, + position: { type: 'text', label: 'Position' }, + organization_id: { type: 'text', label: 'Organization' }, + }, + }, + }, + { + owner: '@objectstack/plugin-security', + def: { + name: 'sys_user_permission_set', + label: 'User Permission Set', + fields: { + user_id: { type: 'text', label: 'User' }, + permission_set_id: { type: 'text', label: 'Permission Set' }, + organization_id: { type: 'text', label: 'Organization' }, + }, + }, + }, + { + owner: '@objectstack/plugin-security', + def: { + name: 'sys_position', + label: 'Position', + fields: { + name: { type: 'text', label: 'Name' }, + active: { type: 'boolean', label: 'Active' }, + organization_id: { type: 'text', label: 'Organization' }, + }, + }, + }, +]; describe('ObjectStackClient (with Hono Server)', () => { let baseUrl: string; @@ -129,6 +227,12 @@ describe('ObjectStackClient (with Hono Server)', () => { // #4065 created a table on first touch and hid that; on SQL the first // write fails with `no such table`. await ql.syncObjectSchema('customer'); + // [#18070] The authz resolver's own reads, registered and synced so the + // driver PROVISIONS them rather than refusing them. + for (const o of AUTHZ_RESOLVER_OBJECTS) { + ql.registerObject(o.def, o.owner); + await ql.syncObjectSchema(o.def.name); + } // 5. Get Port from Service const httpServer = kernel.getService('http.server'); diff --git a/packages/client/src/client.metadata-prefix.test.ts b/packages/client/src/client.metadata-prefix.test.ts index 9d2acaf75b..98b7e18da3 100644 --- a/packages/client/src/client.metadata-prefix.test.ts +++ b/packages/client/src/client.metadata-prefix.test.ts @@ -60,6 +60,7 @@ import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; import { createRestApiPlugin } from '@objectstack/runtime'; import { ObjectStackClient } from './index'; import type { IHttpServer } from '@objectstack/spec/contracts'; +import type { ServiceObject } from '@objectstack/spec/data'; const ENV_ID = 'proj-alpha'; /** @@ -75,6 +76,103 @@ interface Fixture { kernel: LiteKernel; } +/** + * ⚠️ [#18070] The authz objects every authenticated request in this file + * makes core's `resolveUserAuthzGrants` + * (`core/src/security/resolve-authz-context.ts`) read. They belong to + * `@objectstack/plugin-auth` / `@objectstack/plugin-security` and are spelled + * LOCALLY here, carrying only the columns that reading path touches, so this + * suite adds no dependency edge onto either package — the shape PR #17982 and + * PR #18067 landed for the same defect. + * + * Without them the driver REFUSED every one of those reads. Measured on + * `fb29f62ce`, classified by the `(table, filter, limit)` triple of the eight + * reads the resolver issues: **95** resolver-class `refused a read on` driver + * lines in this file, 19 per table across 5. `tryFind` classifies a missing + * table as "not provisioned" and answers `[]`, so nothing went red: every + * assertion below passed over grant reads that never happened — a green this + * suite had not earned, and one it could not lose if grant resolution broke. + * + * ⛔ Registering the tables is what makes those reads SUCCEED. The count must + * ⛔ not fall by silencing, filtering or re-levelling the driver line. + * + * Columns, and why each is here — every other column of the real objects is + * deliberately absent, because no read on this path touches it. `id` is not + * declared anywhere below: the registry supplies the primary key itself, and + * it is what `sys_user`'s `id` filter reads. + * `sys_user` email (the `current_user.email` owner-RLS fallback) + * `sys_member` user_id / organization_id (both filters), role + * `sys_user_position` user_id (filter), position, organization_id + * `sys_user_permission_set` user_id (filter), permission_set_id, organization_id + * `sys_position` name (filter), active (`isRowActive`), + * organization_id (the driver's tenant scope) + * + * ⛔ `sys_position_permission_set` and `sys_permission_set` are NOT here: the + * resolver reaches them only once a `sys_position` row resolves and a + * permission-set id is collected, and nothing here seeds either — measured, + * neither table appears in this file's refusals, before or after. + */ +const AUTHZ_RESOLVER_OBJECTS: { owner: string; def: ServiceObject }[] = [ + { + owner: '@objectstack/plugin-auth', + def: { + name: 'sys_user', + label: 'User', + fields: { + email: { type: 'text', label: 'Email' }, + }, + }, + }, + { + owner: '@objectstack/plugin-auth', + def: { + name: 'sys_member', + label: 'Member', + fields: { + user_id: { type: 'text', label: 'User' }, + organization_id: { type: 'text', label: 'Organization' }, + role: { type: 'text', label: 'Role' }, + }, + }, + }, + { + owner: '@objectstack/plugin-security', + def: { + name: 'sys_user_position', + label: 'User Position', + fields: { + user_id: { type: 'text', label: 'User' }, + position: { type: 'text', label: 'Position' }, + organization_id: { type: 'text', label: 'Organization' }, + }, + }, + }, + { + owner: '@objectstack/plugin-security', + def: { + name: 'sys_user_permission_set', + label: 'User Permission Set', + fields: { + user_id: { type: 'text', label: 'User' }, + permission_set_id: { type: 'text', label: 'Permission Set' }, + organization_id: { type: 'text', label: 'Organization' }, + }, + }, + }, + { + owner: '@objectstack/plugin-security', + def: { + name: 'sys_position', + label: 'Position', + fields: { + name: { type: 'text', label: 'Name' }, + active: { type: 'boolean', label: 'Active' }, + organization_id: { type: 'text', label: 'Organization' }, + }, + }, + }, +]; + /** * One boot recipe, two prefixes — so the non-default case and the control * differ in exactly the key under test and nothing else. @@ -124,6 +222,12 @@ async function bootServer(metadataPrefix?: string): Promise { }); // Registered after bootstrap, so nothing has issued the DDL yet (#4065). await ql.syncObjectSchema('task'); + // [#18070] The authz resolver's own reads, registered and synced so the + // driver PROVISIONS them rather than refusing them. + for (const o of AUTHZ_RESOLVER_OBJECTS) { + ql.registerObject(o.def, o.owner); + await ql.syncObjectSchema(o.def.name); + } const httpServer = kernel.getService('http.server'); const port = httpServer.getPort!(); From 22576a4a47fd180dca52751df41ae89f1154adc0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 23:17:19 +0000 Subject: [PATCH 2/2] test(runtime): close the two pinned-not-closed authz refusal sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WIP — the two `packages/runtime` integration fixtures now provision the five authz tables locally, and the `silentChannels` pins that asserted the refusals are replaced by an assertion that the same reads succeed. Claude-Session: https://claude.ai/code/session_01TbSMtGzMrtPwh925wDEZd5 Co-authored-by: Claude --- ...ion-schema-conformance.integration.test.ts | 220 +++++++++++++--- .../notifications.hono.integration.test.ts | 240 +++++++++++++----- 2 files changed, 361 insertions(+), 99 deletions(-) diff --git a/packages/runtime/src/notification-schema-conformance.integration.test.ts b/packages/runtime/src/notification-schema-conformance.integration.test.ts index e0a81d9312..299cd3b37a 100644 --- a/packages/runtime/src/notification-schema-conformance.integration.test.ts +++ b/packages/runtime/src/notification-schema-conformance.integration.test.ts @@ -33,7 +33,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { ObjectKernel, Plugin, PluginContext } from '@objectstack/core'; import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; -import { ObjectQLPlugin } from '@objectstack/objectql'; +import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql'; import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { MessagingServicePlugin, MessagingService } from '@objectstack/service-messaging'; import { @@ -44,7 +44,8 @@ import { MarkAllNotificationsReadResponseSchema, NotificationSchema, } from '@objectstack/spec/api'; -import type { IHttpServer } from '@objectstack/spec/contracts'; +import type { IDataEngine, IHttpServer } from '@objectstack/spec/contracts'; +import type { ServiceObject } from '@objectstack/spec/data'; import { createDispatcherPlugin } from './dispatcher-plugin.js'; import { DriverPlugin } from './driver-plugin.js'; @@ -119,26 +120,138 @@ const declaredMarkAllReadKeys = () => new Set(Object.keys((MarkAllNotificationsR // per channel, which `afterAll` asserts. /** - * The six tables the resolver reads and this fixture does not provision. - * Derived by measurement, not from the resolver's source, so a read this - * fixture stops provoking shows up as a changed set rather than silently. + * ⭐ [#18070] The authz objects this fixture PROVISIONS, and why it now does. + * + * Until #18070 these five sat on the ABSENT list below with `sys_setting`, and + * every authenticated request in this file read all five through core's + * `resolveUserAuthzGrants` (`core/src/security/resolve-authz-context.ts`) and + * had every one of those reads REFUSED. `tryFind` classifies a missing table + * as "not provisioned" and answers `[]`, so the file stayed green over grant + * resolutions that never happened — a green it had not earned, and one it + * could not lose if grant resolution broke. The old assertions PINNED that + * symptom (they required the refusals to keep arriving) rather than closing + * the read; #18070 closes it, so those pins move — deliberately, and they are + * replaced rather than deleted (see `expectResolverAuthzReadsSucceed`). + * + * ⚠️ Measured on `fb29f62ce` through the capture's OWN `refusals` counter and + * ⛔ NOT through a log grep: this file routes its refusals through + * `captureExpectedReadRefusals`, which WITHHOLDS the driver line, so + * `grep -c "refused a read on"` over a full run of this file reads a clean + * **0** while every read is still being refused. The counter read: + * `refusals` = { sys_user: 12, sys_member: 12, sys_user_position: 12, + * sys_user_permission_set: 12, sys_position: 12, sys_setting: 3 } — 63 in + * total, 60 of them resolver-class — with `engineFrames` identical. That 63 + * is the same number this file's own header block records, re-derived rather + * than transcribed. + * + * The five are registered LOCALLY below, carrying only the columns that + * reading path touches, so this file adds no dependency edge onto + * `@objectstack/plugin-auth` or `@objectstack/plugin-security` — the shape + * PR #17982 and PR #18067 landed for the same defect. ⛔ The count falls + * because the read SUCCEEDS; it is ⛔ not silenced, filtered or re-levelled. + * + * Columns, and why each is here — every other column of the real objects is + * deliberately absent, because no read on this path touches it. `id` is not + * declared: the registry supplies the primary key itself, and it is what + * `sys_user`'s `id` filter reads. + * `sys_user` email (the `current_user.email` owner-RLS fallback) + * `sys_member` user_id / organization_id (both filters), role + * `sys_user_position` user_id (filter), position, organization_id + * `sys_user_permission_set` user_id (filter), permission_set_id, organization_id + * `sys_position` name (filter), active (`isRowActive`), + * organization_id (the driver's tenant scope) + * + * ⛔ `sys_position_permission_set` and `sys_permission_set` are NOT here: the + * resolver reaches them only once a `sys_position` row resolves and a + * permission-set id is collected, and nothing here seeds either — measured, + * neither table appears in this file's refusals, before or after. */ -const ABSENT_AUTHZ_TABLES = [ - 'sys_user', - 'sys_member', - 'sys_user_position', - 'sys_user_permission_set', - 'sys_position', - 'sys_setting', -] as const; +const AUTHZ_RESOLVER_OBJECTS: { owner: string; def: ServiceObject }[] = [ + { + owner: '@objectstack/plugin-auth', + def: { + name: 'sys_user', + label: 'User', + fields: { + email: { type: 'text', label: 'Email' }, + }, + }, + }, + { + owner: '@objectstack/plugin-auth', + def: { + name: 'sys_member', + label: 'Member', + fields: { + user_id: { type: 'text', label: 'User' }, + organization_id: { type: 'text', label: 'Organization' }, + role: { type: 'text', label: 'Role' }, + }, + }, + }, + { + owner: '@objectstack/plugin-security', + def: { + name: 'sys_user_position', + label: 'User Position', + fields: { + user_id: { type: 'text', label: 'User' }, + position: { type: 'text', label: 'Position' }, + organization_id: { type: 'text', label: 'Organization' }, + }, + }, + }, + { + owner: '@objectstack/plugin-security', + def: { + name: 'sys_user_permission_set', + label: 'User Permission Set', + fields: { + user_id: { type: 'text', label: 'User' }, + permission_set_id: { type: 'text', label: 'Permission Set' }, + organization_id: { type: 'text', label: 'Organization' }, + }, + }, + }, + { + owner: '@objectstack/plugin-security', + def: { + name: 'sys_position', + label: 'Position', + fields: { + name: { type: 'text', label: 'Name' }, + active: { type: 'boolean', label: 'Active' }, + organization_id: { type: 'text', label: 'Organization' }, + }, + }, + }, +]; /** - * The five of them read on EVERY grant resolution, i.e. on every request this - * file makes. `sys_setting` is deliberately NOT here: it is read on only some - * routes (3 of the 12 resolutions), so requiring it would turn a single-test - * `-t` run red without meaning anything. + * The five tables above, by name — the set every grant resolution reads and + * this fixture now SERVES. `expectResolverAuthzReadsSucceed()` below is the + * assertion over them. */ -const ALWAYS_READ_AUTHZ_TABLES = ABSENT_AUTHZ_TABLES.filter((t) => t !== 'sys_setting'); +const PROVISIONED_AUTHZ_TABLES = AUTHZ_RESOLVER_OBJECTS.map((o) => o.def.name); + +/** + * The one table this fixture still does not provision, and the whole of the + * capture's declared set now. ⛔ Derived by measurement, not from a prober's + * source: after the registration above, `sys_setting` is the only `no such + * table` refusal this file still produces (measured: 3 on a full run). It stays + * declared so its line stays out of the shared `Test Core` log, and it stays + * OUT of any required-channel assertion for the reason it always was — it is + * read on only some of this file's routes, so requiring it would turn a + * single-test `-t` run red without meaning anything. + * + * ⭐ Shrinking the declared set is what keeps this capture from becoming a + * mute. `captureDriver` forwards an UNRECOGNISED refusal straight to + * `console.warn`, so a regression that stops provisioning one of the five is + * now LOUD on the driver channel as well as red through + * `expectResolverAuthzReadsSucceed()` — where, while the five were declared, + * the same regression would have been withheld and merely counted. + */ +const ABSENT_AUTHZ_TABLES = ['sys_setting'] as const; /** Minimal `auth` service — `x-test-user` names the principal, absent = anonymous. */ function fakeAuthPlugin(): Plugin { @@ -190,6 +303,15 @@ describe('[#5792] the notification wire bodies conform to the schemas the catalo // reads this scopes all happen later, per request. noise.captureEngine(kernel.getService('objectql')); + // [#18070] The authz resolver's own reads, registered and synced here so + // the driver PROVISIONS them rather than refusing them. After bootstrap, + // so each needs its own DDL pass. + const authzEngine = kernel.getService('objectql'); + for (const o of AUTHZ_RESOLVER_OBJECTS) { + authzEngine.registerObject(o.def, o.owner); + await authzEngine.syncObjectSchema(o.def.name); + } + const httpServer = kernel.getService('http.server'); baseUrl = `http://127.0.0.1:${httpServer.getPort!()}`; messaging = kernel.getService('notification'); @@ -204,29 +326,51 @@ describe('[#5792] the notification wire bodies conform to the schemas the catalo }, 60_000); afterAll(async () => { - if (kernel) { - await Promise.race([ - kernel.shutdown(), - new Promise((resolve) => setTimeout(resolve, 10_000)), - ]); - } - - // [#10380] The capture is a PIN, not a mute — asserted after shutdown so - // a failure here can never leave the kernel running. Every one of these - // reads happens on EVERY grant resolution, so this holds for a single - // filtered test as well as for the whole file. If one goes silent, the - // right repair is to re-derive the list above, NOT to relax this: a - // resolver read that stopped happening is a finding, and a table that - // started resolving means this fixture now provisions it. + // ── [#18070] The #10380 / #13325 pin, turned around. It used to assert + // that the five resolver reads were still being REFUSED here — the symptom + // pinned, not the defect closed. They are provisioned now, so the + // assertion is that they SUCCEED, and `[]` means an empty table rather + // than a missing one. // - // ⚠️ [#13325] STRICTLY MORE than the per-table `withheld.has(table)` loop - // this replaced, never less: that loop read the DRIVER channel only, which - // is exactly why it kept passing while the engine half of the same capture - // was dead. `silentChannels()` requires BOTH channels to have fired for - // each table, and a silent one NAMES ITSELF in the diff. - expect(noise.silentChannels(ALWAYS_READ_AUTHZ_TABLES)).toEqual([]); + // ⚠️ Ordering inverted with it: that read needs a LIVE engine, so it runs + // before shutdown. The invariant the old placement bought — a failure here + // can never leave the kernel running — is kept by the `finally`, which is + // why the shutdown moved into one rather than staying first. + try { + await expectResolverAuthzReadsSucceed(); + } finally { + if (kernel) { + await Promise.race([ + kernel.shutdown(), + new Promise((resolve) => setTimeout(resolve, 10_000)), + ]); + } + } }, 30_000); + /** + * ⭐ [#18070] The replacement for `noise.silentChannels(ALWAYS_READ_AUTHZ_TABLES)` + * — the same pin, turned around. + * + * That assertion required each of the five reads to still be REFUSED: it + * pinned the symptom, and it is exactly what had to move once the read was + * closed. ⛔ It is not simply deleted. What it asserted about grant + * resolution — "these five reads really happen on this path" — is asserted + * here in the direction the fix runs: each read SUCCEEDS, and answers `[]` + * because the state is empty rather than because the table is missing. That + * distinction is the whole of #18070. + * + * ⛔ Remove the `AUTHZ_RESOLVER_OBJECTS` registration in `beforeAll` and + * every one of these rejects — which is what makes this a pin and not a + * decoration. + */ + const expectResolverAuthzReadsSucceed = async (): Promise => { + const data = kernel.getService('data'); + for (const table of PROVISIONED_AUTHZ_TABLES) { + await expect(data.find(table, { where: {} })).resolves.toEqual([]); + } + }; + /** Drive one route as `user`, asserting the shared envelope, and hand back `data`. */ const getJson = async (user: string, path: string, init?: RequestInit) => { const res = await fetch(`${baseUrl}${path}`, { diff --git a/packages/runtime/src/notifications.hono.integration.test.ts b/packages/runtime/src/notifications.hono.integration.test.ts index 777c9daeaa..9f7158bc93 100644 --- a/packages/runtime/src/notifications.hono.integration.test.ts +++ b/packages/runtime/src/notifications.hono.integration.test.ts @@ -4,7 +4,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import type { IDataEngine } from '@objectstack/spec/contracts'; import { ObjectKernel, Plugin, PluginContext } from '@objectstack/core'; import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; -import { ObjectQLPlugin } from '@objectstack/objectql'; +import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql'; import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { MessagingServicePlugin, MessagingService } from '@objectstack/service-messaging'; @@ -12,6 +12,7 @@ import { createDispatcherPlugin } from './dispatcher-plugin.js'; import { DriverPlugin } from './driver-plugin.js'; import { captureExpectedReadRefusals } from './expected-read-refusal-noise.js'; import type { IHttpServer } from '@objectstack/spec/contracts'; +import type { ServiceObject } from '@objectstack/spec/data'; /** * End-to-end regression for framework #3362 (`#3354 not effective on hono`). @@ -64,42 +65,136 @@ function fakeAuthPlugin(): Plugin { } /** - * [#10629] The authz resolver's expected read failures: WITHHELD, and ASSERTED. + * ⭐ [#18070] The authz objects this fixture PROVISIONS, and why it now does. * - * This file is the direct sibling `notification-schema-conformance.integration.test.ts` - * names in its own header, and it carries the same never-provisioned `sys_*` - * authz reads through `resolveUserAuthzGrants` - * (`core/src/security/resolve-authz-context.ts`): the fixture provisions the - * messaging objects and nothing else, so every authenticated request reads six - * `sys_*` tables that were never created. `tryFind` swallows each one by design - * — the resolver is fail-closed and must always resolve — but on the way out - * the driver and the engine each log it. Measured on `origin/main`: 52 - * `refused a read on` lines and 52 matching `ERROR Find operation failed` - * frames, out of a suite whose five tests all PASS. + * Until #18070 these five sat on the ABSENT list below with `sys_setting`, and + * every authenticated request in this file read all five through core's + * `resolveUserAuthzGrants` (`core/src/security/resolve-authz-context.ts`) and + * had every one of those reads REFUSED. `tryFind` classifies a missing table + * as "not provisioned" and answers `[]`, so the file stayed green over grant + * resolutions that never happened — a green it had not earned, and one it + * could not lose if grant resolution broke. The old assertions PINNED that + * symptom (they required the refusals to keep arriving) rather than closing + * the read; #18070 closes it, so those pins move — deliberately, and they are + * replaced rather than deleted (see `expectResolverAuthzReadsSucceed`). * - * PR #10630 ruled the shape for this class and applied it to the sibling; this - * is the same shape through the shared `expected-read-refusal-noise.ts`, whose - * header carries the full rationale (⛔ it withholds ONLY a line that names one - * of these tables AND carries that same table's `no such table` reason, and it - * COUNTS what it withheld so the assertions below can be a PIN rather than a - * mute). + * ⚠️ Measured on `fb29f62ce` through the capture's OWN `refusals` counter and + * ⛔ NOT through a log grep: this file routes its refusals through + * `captureExpectedReadRefusals`, which WITHHOLDS the driver line, so + * `grep -c "refused a read on"` over a full run of this file reads a clean + * **0** while every read is still being refused. The counter read: + * `refusals` = { sys_user: 10, sys_member: 10, sys_user_position: 10, + * sys_user_permission_set: 10, sys_position: 10, sys_setting: 2 } — 52 in + * total, 50 of them resolver-class — with `engineFrames` identical. + * + * The five are registered LOCALLY below, carrying only the columns that + * reading path touches, so this file adds no dependency edge onto + * `@objectstack/plugin-auth` or `@objectstack/plugin-security` — the shape + * PR #17982 and PR #18067 landed for the same defect. ⛔ The count falls + * because the read SUCCEEDS; it is ⛔ not silenced, filtered or re-levelled. + * + * Columns, and why each is here — every other column of the real objects is + * deliberately absent, because no read on this path touches it. `id` is not + * declared: the registry supplies the primary key itself, and it is what + * `sys_user`'s `id` filter reads. + * `sys_user` email (the `current_user.email` owner-RLS fallback) + * `sys_member` user_id / organization_id (both filters), role + * `sys_user_position` user_id (filter), position, organization_id + * `sys_user_permission_set` user_id (filter), permission_set_id, organization_id + * `sys_position` name (filter), active (`isRowActive`), + * organization_id (the driver's tenant scope) + * + * ⛔ `sys_position_permission_set` and `sys_permission_set` are NOT here: the + * resolver reaches them only once a `sys_position` row resolves and a + * permission-set id is collected, and nothing here seeds either — measured, + * neither table appears in this file's refusals, before or after. + */ +const AUTHZ_RESOLVER_OBJECTS: { owner: string; def: ServiceObject }[] = [ + { + owner: '@objectstack/plugin-auth', + def: { + name: 'sys_user', + label: 'User', + fields: { + email: { type: 'text', label: 'Email' }, + }, + }, + }, + { + owner: '@objectstack/plugin-auth', + def: { + name: 'sys_member', + label: 'Member', + fields: { + user_id: { type: 'text', label: 'User' }, + organization_id: { type: 'text', label: 'Organization' }, + role: { type: 'text', label: 'Role' }, + }, + }, + }, + { + owner: '@objectstack/plugin-security', + def: { + name: 'sys_user_position', + label: 'User Position', + fields: { + user_id: { type: 'text', label: 'User' }, + position: { type: 'text', label: 'Position' }, + organization_id: { type: 'text', label: 'Organization' }, + }, + }, + }, + { + owner: '@objectstack/plugin-security', + def: { + name: 'sys_user_permission_set', + label: 'User Permission Set', + fields: { + user_id: { type: 'text', label: 'User' }, + permission_set_id: { type: 'text', label: 'Permission Set' }, + organization_id: { type: 'text', label: 'Organization' }, + }, + }, + }, + { + owner: '@objectstack/plugin-security', + def: { + name: 'sys_position', + label: 'Position', + fields: { + name: { type: 'text', label: 'Name' }, + active: { type: 'boolean', label: 'Active' }, + organization_id: { type: 'text', label: 'Organization' }, + }, + }, + }, +]; + +/** + * The five tables above, by name — the set every grant resolution reads and + * this fixture now SERVES. `expectResolverAuthzReadsSucceed()` below is the + * assertion over them. */ -const ABSENT_AUTHZ_TABLES = [ - 'sys_user', - 'sys_member', - 'sys_user_position', - 'sys_user_permission_set', - 'sys_position', - 'sys_setting', -] as const; +const PROVISIONED_AUTHZ_TABLES = AUTHZ_RESOLVER_OBJECTS.map((o) => o.def.name); /** - * The five read on EVERY grant resolution, i.e. on every authenticated request - * this file makes. `sys_setting` is deliberately NOT here: it is read on only - * some routes, so requiring it would turn a single-test `-t` run red without - * meaning anything — it is still withheld when it does fire. + * The one table this fixture still does not provision, and the whole of the + * capture's declared set now. ⛔ Derived by measurement, not from a prober's + * source: after the registration above, `sys_setting` is the only `no such + * table` refusal this file still produces (measured: 2 on a full run). It stays + * declared so its line stays out of the shared `Test Core` log, and it stays + * OUT of any required-channel assertion for the reason it always was — it is + * read on only some of this file's routes, so requiring it would turn a + * single-test `-t` run red without meaning anything. + * + * ⭐ Shrinking the declared set is what keeps this capture from becoming a + * mute. `captureDriver` forwards an UNRECOGNISED refusal straight to + * `console.warn`, so a regression that stops provisioning one of the five is + * now LOUD on the driver channel as well as red through + * `expectResolverAuthzReadsSucceed()` — where, while the five were declared, + * the same regression would have been withheld and merely counted. */ -const ALWAYS_READ_AUTHZ_TABLES = ABSENT_AUTHZ_TABLES.filter((t) => t !== 'sys_setting'); +const ABSENT_AUTHZ_TABLES = ['sys_setting'] as const; describe('in-app notifications over a real hono server (integration, #3362)', () => { let kernel: ObjectKernel; @@ -139,6 +234,15 @@ describe('in-app notifications over a real hono server (integration, #3362)', () // reads this scopes all happen later, per request. noise.captureEngine(kernel.getService('objectql')); + // [#18070] The authz resolver's own reads, registered and synced here so + // the driver PROVISIONS them rather than refusing them. After bootstrap, + // so each needs its own DDL pass. + const authzEngine = kernel.getService('objectql'); + for (const o of AUTHZ_RESOLVER_OBJECTS) { + authzEngine.registerObject(o.def, o.owner); + await authzEngine.syncObjectSchema(o.def.name); + } + const httpServer = kernel.getService('http.server'); baseUrl = `http://127.0.0.1:${httpServer.getPort!()}`; messaging = kernel.getService('notification'); @@ -153,6 +257,29 @@ describe('in-app notifications over a real hono server (integration, #3362)', () } }, 30_000); + /** + * ⭐ [#18070] The replacement for `noise.silentChannels(ALWAYS_READ_AUTHZ_TABLES)` + * — the same pin, turned around. + * + * That assertion required each of the five reads to still be REFUSED: it + * pinned the symptom, and it is exactly what had to move once the read was + * closed. ⛔ It is not simply deleted. What it asserted about grant + * resolution — "these five reads really happen on this path" — is asserted + * here in the direction the fix runs: each read SUCCEEDS, and answers `[]` + * because the state is empty rather than because the table is missing. That + * distinction is the whole of #18070. + * + * ⛔ Remove the `AUTHZ_RESOLVER_OBJECTS` registration in `beforeAll` and + * every one of these rejects — which is what makes this a pin and not a + * decoration. + */ + const expectResolverAuthzReadsSucceed = async (): Promise => { + const data = kernel.getService('data'); + for (const table of PROVISIONED_AUTHZ_TABLES) { + await expect(data.find(table, { where: {} })).resolves.toEqual([]); + } + }; + const as = (user: string, path: string, init?: RequestInit) => fetch(`${baseUrl}${path}`, { ...init, @@ -212,16 +339,13 @@ describe('in-app notifications over a real hono server (integration, #3362)', () expect(served.status).toBe(200); expect((await served.json() as { success: boolean }).success).toBe(true); - // ── [#10629] The capture is a PIN, not a mute. These lines used to reach - // the shared `Test Core` log out of a PASSING test and were read there as a - // real failure; they are withheld now and asserted here. Asserted per authed - // test rather than in `afterAll` because two tests in this file resolve no - // grants at all (discovery, and the anonymous 401), and an `afterAll` would - // make a single-test `-t` run of either of them red for no reason. - // ⛔ If one of these goes silent the repair is to re-derive the list above, - // NOT to relax this: a resolver read that stopped happening is a finding, - // and a table that started resolving means this fixture now provisions it. - expect(noise.silentChannels(ALWAYS_READ_AUTHZ_TABLES)).toEqual([]); + // ── [#18070] The #10629 pin, turned around: this used to assert that the + // five resolver reads were still being REFUSED here. They are provisioned + // now, so the assertion is that they SUCCEED. Kept per authed test rather + // than moved to `afterAll` for the reason the old one was — two tests in + // this file resolve no grants at all (discovery, and the anonymous 401) — + // and because the read below needs a live engine. + await expectResolverAuthzReadsSucceed(); }); it('lists, marks specific read, then marks all read — flipping receipts and clearing the unread count', async () => { @@ -268,16 +392,13 @@ describe('in-app notifications over a real hono server (integration, #3362)', () expect(receipts.length).toBe(2); expect(receipts.every((r: any) => r.state === 'read')).toBe(true); - // ── [#10629] The capture is a PIN, not a mute. These lines used to reach - // the shared `Test Core` log out of a PASSING test and were read there as a - // real failure; they are withheld now and asserted here. Asserted per authed - // test rather than in `afterAll` because two tests in this file resolve no - // grants at all (discovery, and the anonymous 401), and an `afterAll` would - // make a single-test `-t` run of either of them red for no reason. - // ⛔ If one of these goes silent the repair is to re-derive the list above, - // NOT to relax this: a resolver read that stopped happening is a finding, - // and a table that started resolving means this fixture now provisions it. - expect(noise.silentChannels(ALWAYS_READ_AUTHZ_TABLES)).toEqual([]); + // ── [#18070] The #10629 pin, turned around: this used to assert that the + // five resolver reads were still being REFUSED here. They are provisioned + // now, so the assertion is that they SUCCEED. Kept per authed test rather + // than moved to `afterAll` for the reason the old one was — two tests in + // this file resolve no grants at all (discovery, and the anonymous 401) — + // and because the read below needs a live engine. + await expectResolverAuthzReadsSucceed(); }); it('[#6436] mark-all-read clears an inbox LARGER than the list window — no readCount/unreadCount contradiction', async () => { @@ -329,15 +450,12 @@ describe('in-app notifications over a real hono server (integration, #3362)', () expect(receipts.length).toBe(TOTAL); expect(receipts.every((r: any) => r.state === 'read')).toBe(true); - // ── [#10629] The capture is a PIN, not a mute. These lines used to reach - // the shared `Test Core` log out of a PASSING test and were read there as a - // real failure; they are withheld now and asserted here. Asserted per authed - // test rather than in `afterAll` because two tests in this file resolve no - // grants at all (discovery, and the anonymous 401), and an `afterAll` would - // make a single-test `-t` run of either of them red for no reason. - // ⛔ If one of these goes silent the repair is to re-derive the list above, - // NOT to relax this: a resolver read that stopped happening is a finding, - // and a table that started resolving means this fixture now provisions it. - expect(noise.silentChannels(ALWAYS_READ_AUTHZ_TABLES)).toEqual([]); + // ── [#18070] The #10629 pin, turned around: this used to assert that the + // five resolver reads were still being REFUSED here. They are provisioned + // now, so the assertion is that they SUCCEED. Kept per authed test rather + // than moved to `afterAll` for the reason the old one was — two tests in + // this file resolve no grants at all (discovery, and the anonymous 401) — + // and because the read below needs a live engine. + await expectResolverAuthzReadsSucceed(); }, 120_000); });