@@ -7,6 +7,7 @@ import { db, dbFor } from '@sim/db'
77import {
88 organization ,
99 outboxEvent ,
10+ permissions ,
1011 user ,
1112 workspace ,
1213 workspaceFileSecretProvenance ,
@@ -40,6 +41,7 @@ import {
4041 fetchWorkspaceFileBuffer ,
4142 getWorkspaceFile ,
4243 getWorkspaceFileWithCurrentVersion ,
44+ purgeCreatedWorkspaceFile ,
4345 updateWorkspaceFileContent ,
4446 uploadWorkspaceFile ,
4547} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
@@ -61,7 +63,9 @@ import {
6163 readWorkspaceFileVersionText ,
6264 revertWorkspaceFileVersion ,
6365} from '@/lib/workspace-files/application/file-versions'
66+ import { revokeWorkspaceAccessTx } from '@/lib/workspaces/access/workspace-access'
6467import { runCleanupFileVersions } from '@/background/cleanup-file-versions'
68+ import { runCleanupSoftDeletes } from '@/background/cleanup-soft-deletes'
6569import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection'
6670import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
6771
@@ -120,6 +124,50 @@ describe('workspace file version history in PostgreSQL', () => {
120124 return ( await fetchWorkspaceFileBuffer ( { ...file , key } , { maxBytes : 1024 } ) ) . toString ( )
121125 }
122126
127+ async function runContendedFileOperations (
128+ fileId : string ,
129+ operations : ( ( ) => Promise < unknown > ) [ ]
130+ ) {
131+ const ready = createDeferred < number > ( )
132+ const release = createDeferred < void > ( )
133+ const blocker = db . transaction ( async ( tx ) => {
134+ await tx . select ( ) . from ( workspaceFiles ) . where ( eq ( workspaceFiles . id , fileId ) ) . for ( 'update' )
135+ const [ connection ] = await tx . execute < { pid : number } > ( sql `SELECT pg_backend_pid() AS pid` )
136+ ready . resolve ( connection . pid )
137+ await release . promise
138+ } )
139+ const blockerPid = await ready . promise
140+ const pending : Promise < PromiseSettledResult < unknown > [ ] > [ ] = [ ]
141+ try {
142+ for ( const operation of operations ) {
143+ pending . push ( Promise . allSettled ( [ operation ( ) ] ) )
144+ await expect
145+ . poll (
146+ async ( ) =>
147+ (
148+ await db . execute (
149+ sql `WITH RECURSIVE blocked(pid) AS (
150+ SELECT pid FROM pg_stat_activity
151+ WHERE ${ blockerPid } = ANY(pg_blocking_pids(pid))
152+ UNION
153+ SELECT activity.pid FROM pg_stat_activity activity
154+ JOIN blocked ON blocked.pid = ANY(pg_blocking_pids(activity.pid))
155+ ) SELECT pid FROM blocked`
156+ )
157+ ) . length ,
158+ { timeout : 5000 }
159+ )
160+ . toBe ( pending . length )
161+ }
162+ } finally {
163+ release . resolve ( )
164+ await blocker
165+ expect (
166+ ( await Promise . all ( pending ) ) . flat ( ) . filter ( ( result ) => result . status === 'rejected' )
167+ ) . toEqual ( [ ] )
168+ }
169+ }
170+
123171 it ( 'lists a never-rewritten file as an implicit version 1 attributed to its uploader' , async ( ) => {
124172 const fixture = await seedFile ( 'original' )
125173 const file = await getWorkspaceFile ( fixture . workspaceId , fixture . fileId )
@@ -402,26 +450,24 @@ describe('workspace file version history in PostgreSQL', () => {
402450 { first : 'handoff' , content : '' } ,
403451 { first : 'write' , content : '' } ,
404452 ] as const ) (
405- 'serializes creator handoff with a content write ($first first, original=$content)' ,
453+ 'serializes full member revocation with a size-changing content write ($first first, original=$content)' ,
406454 async ( { first, content } ) => {
407455 const fixture = await seedFile ( content )
408- const ready = createDeferred < number > ( )
409- const release = createDeferred < void > ( )
410- const blocker = db . transaction ( async ( tx ) => {
411- await tx
412- . select ( )
413- . from ( workspaceFiles )
414- . where ( eq ( workspaceFiles . id , fixture . fileId ) )
415- . for ( 'update' )
416- const [ connection ] = await tx . execute < { pid : number } > ( sql `SELECT pg_backend_pid() AS pid` )
417- ready . resolve ( connection . pid )
418- await release . promise
419- } )
420- const blockerPid = await ready . promise
456+ await db
457+ . update ( workspaceFiles )
458+ . set ( { userId : fixture . bobId } )
459+ . where ( eq ( workspaceFiles . id , fixture . fileId ) )
460+ const [ usageBefore ] = await db
461+ . select ( { bytes : workspace . storageUsedBytes } )
462+ . from ( workspace )
463+ . where ( eq ( workspace . id , fixture . workspaceId ) )
421464 const operations = {
422465 handoff : ( ) =>
423466 db . transaction ( ( tx ) =>
424- handoffFileCreatorsInTx ( tx , eq ( workspaceFiles . id , fixture . fileId ) , fixture . bobId )
467+ revokeWorkspaceAccessTx ( tx , {
468+ workspaceId : fixture . workspaceId ,
469+ userId : fixture . bobId ,
470+ } ) . then ( ( result ) => expect ( result . revoked ) . toBe ( true ) )
425471 ) ,
426472 write : ( ) =>
427473 updateWorkspaceFileContent (
@@ -433,38 +479,15 @@ describe('workspace file version history in PostgreSQL', () => {
433479 { version : { source : 'api' , authorUserId : fixture . aliceId } }
434480 ) ,
435481 }
436- const pending : Promise < unknown > [ ] = [ ]
437- try {
438- for ( const operation of [ first , first === 'handoff' ? 'write' : 'handoff' ] as const ) {
439- pending . push ( operations [ operation ] ( ) )
440- await expect
441- . poll (
442- async ( ) =>
443- (
444- await db . execute (
445- sql `WITH RECURSIVE blocked(pid) AS (
446- SELECT pid FROM pg_stat_activity
447- WHERE ${ blockerPid } = ANY(pg_blocking_pids(pid))
448- UNION
449- SELECT activity.pid FROM pg_stat_activity activity
450- JOIN blocked ON blocked.pid = ANY(pg_blocking_pids(activity.pid))
451- ) SELECT pid FROM blocked`
452- )
453- ) . length ,
454- { timeout : 5000 }
455- )
456- . toBe ( pending . length )
457- }
458- } finally {
459- release . resolve ( )
460- await blocker
461- await Promise . all ( pending )
462- }
482+ await runContendedFileOperations ( fixture . fileId , [
483+ operations [ first ] ,
484+ operations [ first === 'handoff' ? 'write' : 'handoff' ] ,
485+ ] )
463486 const rows = await versionRows ( fixture . fileId )
464487 expect ( rows . map ( ( row ) => [ row . version , row . authorUserIds ] ) ) . toEqual (
465488 content
466489 ? [
467- [ 1 , [ fixture . aliceId ] ] ,
490+ [ 1 , [ fixture . bobId ] ] ,
468491 [ 2 , [ fixture . aliceId ] ] ,
469492 ]
470493 : [ [ 1 , [ fixture . aliceId ] ] ]
@@ -474,8 +497,140 @@ describe('workspace file version history in PostgreSQL', () => {
474497 content || 'second'
475498 )
476499 expect ( ( await getWorkspaceFile ( fixture . workspaceId , fixture . fileId ) ) ?. uploadedBy ) . toBe (
477- fixture . bobId
500+ fixture . aliceId
478501 )
502+ const [ usageAfter ] = await db
503+ . select ( { bytes : workspace . storageUsedBytes } )
504+ . from ( workspace )
505+ . where ( eq ( workspace . id , fixture . workspaceId ) )
506+ expect ( usageAfter . bytes ) . toBe (
507+ usageBefore . bytes + Buffer . byteLength ( 'second' ) - Buffer . byteLength ( content )
508+ )
509+ expect (
510+ await db
511+ . select ( { id : permissions . id } )
512+ . from ( permissions )
513+ . where (
514+ and (
515+ eq ( permissions . entityId , fixture . workspaceId ) ,
516+ eq ( permissions . entityType , 'workspace' ) ,
517+ eq ( permissions . userId , fixture . bobId )
518+ )
519+ )
520+ ) . toEqual ( [ ] )
521+ const current = await getWorkspaceFile ( fixture . workspaceId , fixture . fileId )
522+ if ( ! current ) throw new Error ( 'file missing' )
523+ expect ( ( await fetchWorkspaceFileBuffer ( current , { maxBytes : 1024 } ) ) . toString ( ) ) . toBe (
524+ 'second'
525+ )
526+ }
527+ )
528+
529+ it . each ( [ 'handoff' , 'purge' ] as const ) (
530+ 'serializes member revocation with archive rollback (%s first)' ,
531+ async ( first ) => {
532+ const fixture = await seedFile ( 'original' )
533+ await db
534+ . update ( workspaceFiles )
535+ . set ( { userId : fixture . bobId } )
536+ . where ( eq ( workspaceFiles . id , fixture . fileId ) )
537+ const file = await getWorkspaceFile ( fixture . workspaceId , fixture . fileId )
538+ if ( ! file ) throw new Error ( 'file missing' )
539+ const [ usageBefore ] = await db
540+ . select ( { bytes : workspace . storageUsedBytes } )
541+ . from ( workspace )
542+ . where ( eq ( workspace . id , fixture . workspaceId ) )
543+ const operations = {
544+ handoff : ( ) =>
545+ db . transaction ( async ( tx ) => {
546+ const result = await revokeWorkspaceAccessTx ( tx , {
547+ workspaceId : fixture . workspaceId ,
548+ userId : fixture . bobId ,
549+ } )
550+ expect ( result . revoked ) . toBe ( true )
551+ } ) ,
552+ purge : async ( ) => {
553+ const purged = await purgeCreatedWorkspaceFile ( {
554+ workspaceId : fixture . workspaceId ,
555+ fileId : fixture . fileId ,
556+ key : file . key ,
557+ expectedName : file . name ,
558+ expectedFolderId : file . folderId ?? null ,
559+ expectedUpdatedAt : file . updatedAt ,
560+ } )
561+ expect ( purged ) . toBe ( first === 'purge' )
562+ } ,
563+ }
564+ await runContendedFileOperations ( fixture . fileId , [
565+ operations [ first ] ,
566+ operations [ first === 'handoff' ? 'purge' : 'handoff' ] ,
567+ ] )
568+ const retained = await getWorkspaceFile ( fixture . workspaceId , fixture . fileId )
569+ const [ usageAfter ] = await db
570+ . select ( { bytes : workspace . storageUsedBytes } )
571+ . from ( workspace )
572+ . where ( eq ( workspace . id , fixture . workspaceId ) )
573+ expect ( usageAfter . bytes ) . toBe (
574+ usageBefore . bytes - ( first === 'purge' ? Buffer . byteLength ( 'original' ) : 0 )
575+ )
576+ expect ( await objectExists ( fixture . firstKey ) ) . toBe ( first === 'handoff' )
577+ if ( first === 'purge' ) {
578+ expect ( retained ) . toBeNull ( )
579+ expect ( await versionRows ( fixture . fileId ) ) . toEqual ( [ ] )
580+ } else {
581+ expect ( retained ?. uploadedBy ) . toBe ( fixture . aliceId )
582+ expect ( ( await versionRows ( fixture . fileId ) ) . map ( ( row ) => row . authorUserIds ) ) . toEqual ( [
583+ [ fixture . bobId ] ,
584+ ] )
585+ }
586+ }
587+ )
588+
589+ it . each ( [ 'handoff' , 'cleanup' ] as const ) (
590+ 'serializes member revocation with retention cleanup (%s first)' ,
591+ async ( first ) => {
592+ const fixture = await seedFile ( 'original' )
593+ await db
594+ . update ( workspaceFiles )
595+ . set ( { userId : fixture . bobId , deletedAt : new Date ( 0 ) } )
596+ . where ( eq ( workspaceFiles . id , fixture . fileId ) )
597+ const [ usageBefore ] = await db
598+ . select ( { bytes : workspace . storageUsedBytes } )
599+ . from ( workspace )
600+ . where ( eq ( workspace . id , fixture . workspaceId ) )
601+ const operations = {
602+ handoff : ( ) =>
603+ db . transaction ( async ( tx ) => {
604+ const result = await revokeWorkspaceAccessTx ( tx , {
605+ workspaceId : fixture . workspaceId ,
606+ userId : fixture . bobId ,
607+ } )
608+ expect ( result . revoked ) . toBe ( true )
609+ } ) ,
610+ cleanup : ( ) =>
611+ runCleanupSoftDeletes ( {
612+ workspaceIds : [ fixture . workspaceId ] ,
613+ plan : 'free' ,
614+ retentionHours : 1 ,
615+ label : 'file-handoff-lock-regression' ,
616+ } ) ,
617+ }
618+ await runContendedFileOperations ( fixture . fileId , [
619+ operations [ first ] ,
620+ operations [ first === 'handoff' ? 'cleanup' : 'handoff' ] ,
621+ ] )
622+ expect (
623+ await db
624+ . select ( { id : workspaceFiles . id } )
625+ . from ( workspaceFiles )
626+ . where ( eq ( workspaceFiles . id , fixture . fileId ) )
627+ ) . toEqual ( [ ] )
628+ expect ( await versionRows ( fixture . fileId ) ) . toEqual ( [ ] )
629+ const [ usageAfter ] = await db
630+ . select ( { bytes : workspace . storageUsedBytes } )
631+ . from ( workspace )
632+ . where ( eq ( workspace . id , fixture . workspaceId ) )
633+ expect ( usageAfter . bytes ) . toBe ( usageBefore . bytes - Buffer . byteLength ( 'original' ) )
479634 }
480635 )
481636
0 commit comments