@@ -42,10 +42,13 @@ import {
4242import { archiveProjectInTransaction } from '@/lib/projects/lifecycle'
4343import {
4444 createProjectForWorkspace ,
45+ lockProject ,
4546 lockWorkspaceProject ,
47+ splitForkProject ,
4648 transferWorkspaceProjects ,
4749} from '@/lib/projects/membership'
48- import { restoreWorkflow } from '@/lib/workflows/lifecycle'
50+ import { getAccountDeletionPlan } from '@/lib/users/account-deletion'
51+ import { disableUserResources , restoreWorkflow } from '@/lib/workflows/lifecycle'
4952import { buildNewWorkflowRow } from '@/lib/workflows/persistence/new-workflow-row'
5053import { createWorkspaceInTransaction } from '@/lib/workspaces/create'
5154import { archiveWorkspace } from '@/lib/workspaces/lifecycle'
@@ -1083,6 +1086,148 @@ describe('Project foundation at the database and application boundary', () => {
10831086 }
10841087 )
10851088
1089+ check (
1090+ 'account deletion preview reports a surviving Project losing its last active environment' ,
1091+ async ( ) => {
1092+ const f = await fixture ( false )
1093+ await db
1094+ . delete ( permissions )
1095+ . where ( and ( eq ( permissions . entityId , f . ids [ 0 ] ) , eq ( permissions . userId , f . teammateId ) ) )
1096+ await db
1097+ . delete ( permissions )
1098+ . where ( and ( eq ( permissions . entityId , f . ids [ 1 ] ) , eq ( permissions . userId , f . ownerId ) ) )
1099+ await db . insert ( permissions ) . values ( {
1100+ id : generateId ( ) ,
1101+ entityId : f . ids [ 1 ] ,
1102+ entityType : 'workspace' ,
1103+ userId : f . teammateId ,
1104+ permissionType : 'admin' ,
1105+ } )
1106+ await db
1107+ . update ( workspace )
1108+ . set ( { archivedAt : new Date ( ) , ownerId : f . teammateId , billedAccountUserId : f . teammateId } )
1109+ . where ( eq ( workspace . id , f . ids [ 1 ] ) )
1110+ const plan = await getAccountDeletionPlan ( f . ownerId )
1111+ expect ( plan . workspacesToDelete . map ( ( row ) => row . id ) ) . toEqual ( [ f . ids [ 0 ] ] )
1112+ expect ( plan . blockers ) . toEqual ( [
1113+ { code : 'project_lifecycle' , message : expect . stringContaining ( 'Archive' ) } ,
1114+ ] )
1115+ await expect (
1116+ db . transaction ( ( tx ) => prepareProjectsForAccountDeletion ( tx , f . ownerId , [ f . ids [ 0 ] ] ) )
1117+ ) . rejects . toMatchObject ( { code : 'conflict' } )
1118+ await db . transaction ( ( tx ) => archiveProjectInTransaction ( tx , f . projectId ) )
1119+ expect ( ( await getAccountDeletionPlan ( f . ownerId ) ) . blockers ) . toEqual ( [ ] )
1120+ }
1121+ )
1122+
1123+ check (
1124+ 'account teardown can choose an organization admin without per-environment grants' ,
1125+ async ( ) => {
1126+ const f = await fixture ( )
1127+ if ( ! f . organizationId ) throw new Error ( 'Missing organization fixture' )
1128+ await db . insert ( member ) . values ( {
1129+ id : generateId ( ) ,
1130+ organizationId : f . organizationId ,
1131+ userId : f . teammateId ,
1132+ role : 'admin' ,
1133+ createdAt : new Date ( ) ,
1134+ } )
1135+ await db . transaction ( ( tx ) => prepareProjectsForAccountDeletion ( tx , f . ownerId , [ ] ) )
1136+ const [ record ] = await db . select ( ) . from ( project ) . where ( eq ( project . id , f . projectId ) )
1137+ expect ( record . ownerId ) . toBe ( f . teammateId )
1138+ }
1139+ )
1140+
1141+ check (
1142+ 'account teardown re-reads Projects created by an unlink while waiting for the old Project' ,
1143+ async ( ) => {
1144+ const f = await fixture ( false )
1145+ const held = createDeferred < number > ( )
1146+ const release = createDeferred < void > ( )
1147+ let detachedProjectId : string | null = null
1148+ const unlink = db . transaction ( async ( tx ) => {
1149+ await lockProject ( tx , f . projectId )
1150+ const [ connection ] = await tx . execute < { pid : number } > ( sql `SELECT pg_backend_pid() AS pid` )
1151+ held . resolve ( connection . pid )
1152+ await release . promise
1153+ detachedProjectId = await splitForkProject ( tx , f . ids [ 1 ] )
1154+ await tx
1155+ . update ( workspace )
1156+ . set ( { forkedFromWorkspaceId : null } )
1157+ . where ( eq ( workspace . id , f . ids [ 1 ] ) )
1158+ } )
1159+ const blocker = await held . promise
1160+ const deletion = db . transaction ( ( tx ) =>
1161+ prepareProjectsForAccountDeletion ( tx , f . ownerId , [ f . ids [ 1 ] ] )
1162+ )
1163+ try {
1164+ let waiting = false
1165+ for ( let attempt = 0 ; attempt < 100 ; attempt ++ ) {
1166+ const rows = await db . execute (
1167+ sql `SELECT 1 FROM pg_stat_activity WHERE ${ blocker } = ANY(pg_blocking_pids(pid))`
1168+ )
1169+ if ( rows . length ) {
1170+ waiting = true
1171+ break
1172+ }
1173+ await sleep ( 10 )
1174+ }
1175+ expect ( waiting ) . toBe ( true )
1176+ } finally {
1177+ release . resolve ( )
1178+ await unlink
1179+ }
1180+ await deletion
1181+ if ( ! detachedProjectId ) throw new Error ( 'Unlink did not create a Project' )
1182+ expect ( await db . select ( ) . from ( project ) . where ( eq ( project . id , detachedProjectId ) ) ) . toEqual ( [ ] )
1183+ }
1184+ )
1185+
1186+ check (
1187+ 'banning a former owner cannot archive environments transferred while waiting for the Project' ,
1188+ async ( ) => {
1189+ const f = await fixture ( false )
1190+ const held = createDeferred < number > ( )
1191+ const release = createDeferred < void > ( )
1192+ const transfer = db . transaction ( async ( tx ) => {
1193+ await lockProject ( tx , f . projectId )
1194+ const [ connection ] = await tx . execute < { pid : number } > ( sql `SELECT pg_backend_pid() AS pid` )
1195+ held . resolve ( connection . pid )
1196+ await release . promise
1197+ await tx
1198+ . update ( workspace )
1199+ . set ( { ownerId : f . teammateId } )
1200+ . where ( inArray ( workspace . id , f . ids ) )
1201+ } )
1202+ const blocker = await held . promise
1203+ const ban = disableUserResources ( f . ownerId )
1204+ try {
1205+ let waiting = false
1206+ for ( let attempt = 0 ; attempt < 100 ; attempt ++ ) {
1207+ const rows = await db . execute (
1208+ sql `SELECT 1 FROM pg_stat_activity WHERE ${ blocker } = ANY(pg_blocking_pids(pid))`
1209+ )
1210+ if ( rows . length ) {
1211+ waiting = true
1212+ break
1213+ }
1214+ await sleep ( 10 )
1215+ }
1216+ expect ( waiting ) . toBe ( true )
1217+ } finally {
1218+ release . resolve ( )
1219+ await transfer
1220+ }
1221+ await ban
1222+ const rows = await db . select ( ) . from ( workspace ) . where ( inArray ( workspace . id , f . ids ) )
1223+ expect ( rows . every ( ( row ) => row . archivedAt === null && row . ownerId === f . teammateId ) ) . toBe (
1224+ true
1225+ )
1226+ const [ record ] = await db . select ( ) . from ( project ) . where ( eq ( project . id , f . projectId ) )
1227+ expect ( record . archivedAt ) . toBeNull ( )
1228+ }
1229+ )
1230+
10861231 check (
10871232 'account teardown transfers surviving Projects and atomically removes wholly private Projects' ,
10881233 async ( ) => {
0 commit comments