@@ -20,7 +20,7 @@ import {
2020} from '@sim/testing/mocks/table-rows-service.mock'
2121import { workspaceAuthzMock , workspaceAuthzMockFns } from '@sim/testing/mocks/workspace-authz.mock'
2222import { beforeEach , describe , expect , it , vi } from 'vitest'
23- import type { TableDefinition } from '@/lib/table/types'
23+ import type { TableDefinition , TablePredicate } from '@/lib/table/types'
2424
2525const {
2626 mockIsScopeCompatible,
@@ -29,9 +29,9 @@ const {
2929 mockRowProvenance,
3030 mockFindRowMatches,
3131 mockClassifyColumns,
32- mockTableHasUnvouchedRows ,
32+ mockTableHasCellsUnsafe ,
3333} = vi . hoisted ( ( ) => ( {
34- mockTableHasUnvouchedRows : vi . fn ( ) ,
34+ mockTableHasCellsUnsafe : vi . fn ( ) ,
3535 mockFindRowMatches : vi . fn ( ) ,
3636 mockClassifyColumns : vi . fn ( ) ,
3737 mockIsScopeCompatible : vi . fn ( ) ,
@@ -60,7 +60,7 @@ vi.mock('@/lib/table/column-types', () => ({
6060vi . mock ( '@/lib/table/rows/secret-provenance' , ( ) => ( {
6161 ...tableRowsSecretProvenanceMock ,
6262 classifyStoredTableRowColumns : mockClassifyColumns ,
63- tableHasUnvouchedRows : mockTableHasUnvouchedRows ,
63+ tableHasCellsUnsafeForPredicate : mockTableHasCellsUnsafe ,
6464 TableRowProvenanceReader : class extends tableRowsSecretProvenanceMock . TableRowProvenanceReader {
6565 rowProvenance ( rowId : string ) {
6666 return mockRowProvenance ( rowId )
@@ -997,54 +997,86 @@ describe('row delivery to Chat', () => {
997997 }
998998 )
999999
1000- describe ( 'query counts Chat sees ' , ( ) => {
1001- const filtered = ( principal : Caller ) =>
1000+ describe ( 'predicates Chat runs over cell values ' , ( ) => {
1001+ const query = ( principal : Caller , predicate ?: TablePredicate ) =>
10021002 queryTableRows . execute ( {
10031003 principal,
1004- input : {
1005- tableId : TABLE . id ,
1006- limit : 25 ,
1007- includeTotal : true ,
1008- predicate : { all : [ { field : 'name' , op : 'eq' , value : 'guess' } ] } ,
1009- } ,
1004+ input : { tableId : TABLE . id , limit : 25 , includeTotal : true , predicate } ,
10101005 } )
1006+ const BY_NAME : TablePredicate = { all : [ { field : 'name' , op : 'eq' , value : 'guess' } ] }
10111007
10121008 beforeEach ( ( ) => {
10131009 mockQueryRows . mockResolvedValue ( { ...PAGE , rowCount : 1 , totalCount : 3 } )
1010+ mockTableHasCellsUnsafe . mockResolvedValue ( false )
10141011 } )
10151012
10161013 it ( 'counts only the rows delivered on the page' , async ( ) => {
10171014 mockRowProvenance . mockReturnValue ( { status : 'unknown' } )
1018- mockTableHasUnvouchedRows . mockResolvedValue ( false )
1019- const result = await observeContentDelivery ( { admit : gate ( false ) } , ( ) => filtered ( CHAT ) )
1015+ const result = await observeContentDelivery ( { admit : gate ( false ) } , ( ) =>
1016+ query ( CHAT , BY_NAME )
1017+ )
10201018 expect ( result ) . toMatchObject ( { rows : [ ] , rowCount : 0 , totalCount : 3 } )
10211019 } )
10221020
1023- it ( 'refuses a filtered total over a table holding rows Chat may not read' , async ( ) => {
1021+ it ( 'refuses a predicate over cells it cannot vouch for, before running it' , async ( ) => {
1022+ mockTableHasCellsUnsafe . mockResolvedValue ( true )
1023+ await expect (
1024+ observeContentDelivery ( { admit : gate ( true ) } , ( ) => query ( CHAT , BY_NAME ) )
1025+ ) . rejects . toBeInstanceOf ( ContentRefusedError )
1026+ expect ( mockTableHasCellsUnsafe ) . toHaveBeenCalledWith ( TABLE . id , new Set ( [ 'column-name' ] ) )
1027+ expect ( mockQueryRows ) . not . toHaveBeenCalled ( )
1028+ } )
1029+
1030+ it ( 'refuses when a cell it reads becomes unsafe during the read' , async ( ) => {
1031+ mockTableHasCellsUnsafe . mockResolvedValueOnce ( false ) . mockResolvedValueOnce ( true )
10241032 mockRowProvenance . mockReturnValue ( { status : 'exact' , entries : [ ] } )
1025- mockTableHasUnvouchedRows . mockResolvedValue ( true )
10261033 await expect (
1027- observeContentDelivery ( { admit : gate ( true ) } , ( ) => filtered ( CHAT ) )
1034+ observeContentDelivery ( { admit : gate ( true ) } , ( ) => query ( CHAT , BY_NAME ) )
10281035 ) . rejects . toBeInstanceOf ( ContentRefusedError )
10291036 } )
10301037
1031- it ( 'keeps an unfiltered total, which says only how many rows exist ' , async ( ) => {
1038+ it ( 'checks nothing for an id-only predicate or no predicate, which read no cell ' , async ( ) => {
10321039 mockRowProvenance . mockReturnValue ( { status : 'exact' , entries : [ ] } )
1033- mockTableHasUnvouchedRows . mockResolvedValue ( true )
1034- const result = await observeContentDelivery ( { admit : gate ( true ) } , ( ) =>
1035- queryTableRows . execute ( {
1036- principal : CHAT ,
1037- input : { tableId : TABLE . id , limit : 25 , includeTotal : true } ,
1038- } )
1040+ await observeContentDelivery ( { admit : gate ( true ) } , ( ) => query ( CHAT ) )
1041+ await observeContentDelivery ( { admit : gate ( true ) } , ( ) =>
1042+ query ( CHAT , { all : [ { field : 'id' , op : 'in' , value : [ 'row-1' ] } ] } )
10391043 )
1040- expect ( result ) . toMatchObject ( { rowCount : 1 , totalCount : 3 } )
1041- expect ( mockTableHasUnvouchedRows ) . not . toHaveBeenCalled ( )
1044+ expect ( mockTableHasCellsUnsafe ) . not . toHaveBeenCalled ( )
1045+ } )
1046+
1047+ it ( 'refuses a free-text search over a table with any unsafe cell' , async ( ) => {
1048+ mockTableHasCellsUnsafe . mockResolvedValue ( true )
1049+ await expect (
1050+ observeContentDelivery ( { admit : gate ( true ) } , ( ) =>
1051+ searchTableRows . execute ( { principal : CHAT , input : { tableId : TABLE . id , q : 'guess' } } )
1052+ )
1053+ ) . rejects . toBeInstanceOf ( ContentRefusedError )
1054+ expect ( mockTableHasCellsUnsafe ) . toHaveBeenCalledWith ( TABLE . id , 'all' )
1055+ expect ( mockFindRowMatches ) . not . toHaveBeenCalled ( )
1056+ } )
1057+
1058+ it ( 'refuses an update by filter over unsafe cells before changing anything' , async ( ) => {
1059+ mockTableHasCellsUnsafe . mockResolvedValue ( true )
1060+ await expect (
1061+ observeContentDelivery ( { admit : gate ( true ) } , ( ) =>
1062+ updateTableRows . execute ( {
1063+ principal : CHAT ,
1064+ input : {
1065+ tableId : TABLE . id ,
1066+ filter : { name : 'guess' } ,
1067+ data : { name : 'x' } ,
1068+ dataKeying : 'names' ,
1069+ } ,
1070+ } )
1071+ )
1072+ ) . rejects . toBeInstanceOf ( ContentRefusedError )
1073+ expect ( mockUpdateRowsByFilter ) . not . toHaveBeenCalled ( )
10421074 } )
10431075
1044- it ( 'answers a person with the stored counts, unread ' , async ( ) => {
1045- const result = await filtered ( PRINCIPAL )
1076+ it ( 'answers a person with the stored counts, unchecked ' , async ( ) => {
1077+ const result = await query ( PRINCIPAL , BY_NAME )
10461078 expect ( result ) . toMatchObject ( { rowCount : 1 , totalCount : 3 } )
1047- expect ( mockTableHasUnvouchedRows ) . not . toHaveBeenCalled ( )
1079+ expect ( mockTableHasCellsUnsafe ) . not . toHaveBeenCalled ( )
10481080 } )
10491081 } )
10501082
0 commit comments