@@ -4,11 +4,24 @@ import { rm } from 'node:fs/promises'
44import { tmpdir } from 'node:os'
55import path from 'node:path'
66import { db } from '@sim/db'
7- import { auditLog , organization , user , workspace , workspaceFileVersion } from '@sim/db/schema'
7+ import {
8+ auditLog ,
9+ organization ,
10+ permissionGroup ,
11+ permissionGroupMember ,
12+ permissionGroupWorkspace ,
13+ permissions ,
14+ user ,
15+ workspace ,
16+ workspaceFileVersion ,
17+ } from '@sim/db/schema'
18+ import { envFlagsMock } from '@sim/testing/mocks/env-flags.mock'
819import { generateId } from '@sim/utils/id'
920import { and , asc , eq , inArray } from 'drizzle-orm'
1021import { afterAll , beforeAll , describe , expect , it , vi } from 'vitest'
1122
23+ vi . mock ( '@/lib/core/config/env-flags' , ( ) => ( { ...envFlagsMock , isAccessControlEnabled : true } ) )
24+
1225const fixtureStorage = vi . hoisted ( ( ) => ( { root : '' } ) )
1326vi . mock ( '@/lib/uploads/core/setup.server' , ( ) => ( {
1427 get UPLOAD_DIR_SERVER ( ) {
@@ -28,6 +41,7 @@ import {
2841 updateWorkspaceFileContent ,
2942 uploadWorkspaceFile ,
3043} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
44+ import type { WorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance'
3145import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection'
3246import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
3347import '@/app/api/v2/files/[fileId]/versions/route'
@@ -49,7 +63,11 @@ describe('chat-delegated file version history', () => {
4963 fixtureStorage . root = mkdtempSync ( path . join ( tmpdir ( ) , 'sim-chat-file-versions-' ) )
5064 } )
5165
66+ const extraWorkspaceIds : string [ ] = [ ]
67+
5268 afterAll ( async ( ) => {
69+ if ( extraWorkspaceIds . length > 0 )
70+ await db . delete ( workspace ) . where ( inArray ( workspace . id , extraWorkspaceIds ) )
5371 for ( const ids of fixtures ) {
5472 await db . delete ( auditLog ) . where ( eq ( auditLog . workspaceId , ids . workspaceId ) )
5573 await db . delete ( workspace ) . where ( eq ( workspace . id , ids . workspaceId ) )
@@ -60,7 +78,7 @@ describe('chat-delegated file version history', () => {
6078 } )
6179
6280 /** Version 2 holds a named secret; version 3 replaced it with public text. */
63- async function seedVersionedFile ( ) {
81+ async function seedVersionedFile ( historicalProvenance ?: WorkspaceFileSecretProvenance ) {
6482 const ids = createKnowledgeAclFixtureIds ( )
6583 fixtures . push ( ids )
6684 await seedKnowledgeAclFixture ( ids )
@@ -84,7 +102,7 @@ describe('chat-delegated file version history', () => {
84102 ] ,
85103 } as const
86104 for ( const [ content , provenance ] of [
87- [ `token=${ SECRET } ` , secretProvenance ] ,
105+ [ `token=${ SECRET } ` , historicalProvenance ?? secretProvenance ] ,
88106 [ 'public replacement' , { status : 'exact' , entries : [ ] } ] ,
89107 ] as const ) {
90108 await updateWorkspaceFileContent (
@@ -102,25 +120,39 @@ describe('chat-delegated file version history', () => {
102120 return { ...ids , fileId : file . id }
103121 }
104122
105- /** Chat's composed CLI transport: the provenance-observing read layer over in-process admission. */
123+ /**
124+ * Chat's composed CLI transport: the provenance-observing read layer over in-process admission.
125+ * `layers` drops the outer layers to prove the inner ones hold on their own.
126+ */
106127 function chatTransport (
107128 fixture : { workspaceId : string ; organizationId : string } ,
108129 userId : string ,
109- registry ?: ResolvedSecretTraceRegistry
130+ registry ?: ResolvedSecretTraceRegistry ,
131+ layers : { observer ?: boolean ; invocationScope ?: boolean } = { }
110132 ) {
111133 const invocation = { userId, workspaceId : fixture . workspaceId , chatId : generateId ( ) }
112- const transport = createFileReadTransport ( {
113- endpoint : ORIGIN ,
114- transport : createScopedCliTransport ( ORIGIN , invocation ) ,
115- userId,
116- invocation,
117- ...( registry ? { registry } : { } ) ,
118- } )
134+ const scoped = createScopedCliTransport ( ORIGIN , invocation )
135+ const transport =
136+ layers . observer === false
137+ ? scoped
138+ : createFileReadTransport ( {
139+ endpoint : ORIGIN ,
140+ transport : scoped ,
141+ userId,
142+ invocation,
143+ ...( registry ? { registry } : { } ) ,
144+ } )
119145 return ( url : string , init ?: RequestInit ) =>
120- withWorkspaceInvocationScope (
121- { workspaceId : fixture . workspaceId , organizationId : fixture . organizationId } ,
122- ( ) => transport ( `${ ORIGIN } ${ url } ` , init )
123- )
146+ layers . invocationScope === false
147+ ? transport ( `${ ORIGIN } ${ url } ` , init )
148+ : withWorkspaceInvocationScope (
149+ { workspaceId : fixture . workspaceId , organizationId : fixture . organizationId } ,
150+ ( ) => transport ( `${ ORIGIN } ${ url } ` , init )
151+ )
152+ }
153+
154+ function registryFor ( fixture : { workspaceId : string } , userId : string ) {
155+ return new ResolvedSecretTraceRegistry ( [ ] , { userId, workspaceId : fixture . workspaceId } )
124156 }
125157
126158 function versionRows ( fileId : string ) {
@@ -243,4 +275,131 @@ describe('chat-delegated file version history', () => {
243275
244276 expect ( ( await versionRows ( fixture . fileId ) ) . map ( ( row ) => row . version ) ) . toEqual ( [ 1 , 2 , 3 ] )
245277 } )
278+
279+ it ( 'refuses a delegated version read that no delivery observer records' , async ( ) => {
280+ const fixture = await seedVersionedFile ( )
281+
282+ const response = await chatTransport ( fixture , fixture . bobId , undefined , { observer : false } ) (
283+ `/api/v2/files/${ fixture . fileId } /versions/2/text?workspaceId=${ fixture . workspaceId } `
284+ )
285+
286+ expect ( response . status ) . toBe ( 503 )
287+ const body = await response . text ( )
288+ expect ( body ) . not . toContain ( SECRET )
289+ expect ( JSON . parse ( body ) ) . toMatchObject ( { error : { code : 'SERVICE_UNAVAILABLE' } } )
290+ } )
291+
292+ it ( "refuses a file in another of the user's workspaces without the invocation scope" , async ( ) => {
293+ const fixture = await seedVersionedFile ( )
294+ const otherWorkspaceId = generateId ( )
295+ extraWorkspaceIds . push ( otherWorkspaceId )
296+ await db . insert ( workspace ) . values ( {
297+ id : otherWorkspaceId ,
298+ organizationId : fixture . organizationId ,
299+ name : 'Second workspace' ,
300+ ownerId : fixture . aliceId ,
301+ billedAccountUserId : fixture . aliceId ,
302+ } )
303+ await db . insert ( permissions ) . values ( {
304+ id : generateId ( ) ,
305+ userId : fixture . aliceId ,
306+ entityType : 'workspace' ,
307+ entityId : otherWorkspaceId ,
308+ permissionType : 'admin' ,
309+ } )
310+ const chat = chatTransport (
311+ { workspaceId : otherWorkspaceId , organizationId : fixture . organizationId } ,
312+ fixture . aliceId ,
313+ undefined ,
314+ { invocationScope : false }
315+ )
316+
317+ for ( const assertedWorkspaceId of [ fixture . workspaceId , otherWorkspaceId ] ) {
318+ const list = await chat (
319+ `/api/v2/files/${ fixture . fileId } /versions?workspaceId=${ assertedWorkspaceId } `
320+ )
321+ expect ( list . status ) . toBe ( 404 )
322+ expect ( await list . json ( ) ) . toMatchObject ( { error : { code : 'NOT_FOUND' } } )
323+ }
324+ const revert = await chat ( `/api/v2/files/${ fixture . fileId } /versions/1/revert` , {
325+ method : 'POST' ,
326+ headers : { 'content-type' : 'application/json' } ,
327+ body : JSON . stringify ( { workspaceId : fixture . workspaceId } ) ,
328+ } )
329+ expect ( revert . status ) . toBe ( 404 )
330+ expect ( ( await versionRows ( fixture . fileId ) ) . map ( ( row ) => row . version ) ) . toEqual ( [ 1 , 2 , 3 ] )
331+ } )
332+
333+ it ( 'refuses a member whose permission group withholds the Files module' , async ( ) => {
334+ const fixture = await seedVersionedFile ( )
335+ const groupId = generateId ( )
336+ await db . insert ( permissionGroup ) . values ( {
337+ id : groupId ,
338+ organizationId : fixture . organizationId ,
339+ name : 'No files' ,
340+ createdBy : fixture . aliceId ,
341+ config : { hideFilesTab : true } ,
342+ } )
343+ await db . insert ( permissionGroupWorkspace ) . values ( {
344+ id : generateId ( ) ,
345+ permissionGroupId : groupId ,
346+ workspaceId : fixture . workspaceId ,
347+ organizationId : fixture . organizationId ,
348+ } )
349+ await db . insert ( permissionGroupMember ) . values ( {
350+ id : generateId ( ) ,
351+ permissionGroupId : groupId ,
352+ organizationId : fixture . organizationId ,
353+ userId : fixture . bobId ,
354+ } )
355+
356+ const response = await chatTransport (
357+ fixture ,
358+ fixture . bobId
359+ ) ( `/api/v2/files/${ fixture . fileId } /versions?workspaceId=${ fixture . workspaceId } ` )
360+
361+ expect ( response . status ) . toBe ( 403 )
362+ expect ( await response . json ( ) ) . toMatchObject ( {
363+ error : { code : 'FORBIDDEN' , details : { code : 'PERMISSION_GROUP_CAPABILITY_BLOCKED' } } ,
364+ } )
365+ } )
366+
367+ it ( 'withholds a historical version whose secret provenance is unknown' , async ( ) => {
368+ const fixture = await seedVersionedFile ( { status : 'unknown' } )
369+
370+ const response = await chatTransport (
371+ fixture ,
372+ fixture . bobId ,
373+ registryFor ( fixture , fixture . bobId )
374+ ) ( `/api/v2/files/${ fixture . fileId } /versions/2/text?workspaceId=${ fixture . workspaceId } ` )
375+
376+ expect ( response . status ) . toBe ( 503 )
377+ expect ( await response . text ( ) ) . not . toContain ( SECRET )
378+ } )
379+
380+ it ( 'keeps a secret redacted after Chat reverts the file to the version holding it' , async ( ) => {
381+ const fixture = await seedVersionedFile ( )
382+ const reverted = await chatTransport ( fixture , fixture . aliceId ) (
383+ `/api/v2/files/${ fixture . fileId } /versions/2/revert` ,
384+ {
385+ method : 'POST' ,
386+ headers : { 'content-type' : 'application/json' } ,
387+ body : JSON . stringify ( { workspaceId : fixture . workspaceId } ) ,
388+ }
389+ )
390+ expect ( reverted . status ) . toBe ( 200 )
391+ const url = `/api/v2/files/${ fixture . fileId } /text?workspaceId=${ fixture . workspaceId } `
392+ const registry = registryFor ( fixture , fixture . bobId )
393+
394+ const response = await chatTransport ( fixture , fixture . bobId , registry ) ( url )
395+ expect ( response . status ) . toBe ( 200 )
396+ const projected = projectResolvedSecretModelContent ( await response . text ( ) , registry )
397+ if ( ! projected . safe ) throw new Error ( 'Reverted file text was withheld' )
398+ expect ( projected . value ) . not . toContain ( SECRET )
399+ expect ( projected . value ) . toContain ( 'token=[REDACTED_SECRET]' )
400+
401+ const untracked = await chatTransport ( fixture , fixture . bobId ) ( url )
402+ expect ( untracked . status ) . toBe ( 503 )
403+ expect ( await untracked . text ( ) ) . not . toContain ( SECRET )
404+ } )
246405} )
0 commit comments