11import { readFileSync } from 'node:fs'
2+ import { validateFileWorkspaceBindingMigration } from '@sim/db/script-migrations/0034_validate_file_workspace_binding'
3+ import { runScriptMigrations } from '@sim/db/script-migrations/index'
24import { readTestDatabaseUrl } from '@sim/db/testing/test-infrastructure'
35import { createDeferred } from '@sim/testing/helpers/deferred'
46import { sleep } from '@sim/utils/helpers'
@@ -12,13 +14,20 @@ const migration = readFileSync(
1214 'utf8'
1315)
1416
17+ const workspaceBindingMigration = readFileSync (
18+ new URL ( './migrations/0408_file_workspace_binding.sql' , import . meta. url ) ,
19+ 'utf8'
20+ )
21+
1522describe ( 'file entity ownership migration in PostgreSQL' , ( ) => {
1623 const schemaName = `file_entity_${ generateId ( ) . replaceAll ( '-' , '' ) } `
1724 let sql : Sql
1825 let admin : Sql
1926
2027 async function applyMigration ( ) {
21- for ( const statement of migration . split ( '--> statement-breakpoint' ) ) {
28+ for ( const statement of `${ migration } \n--> statement-breakpoint\n${ workspaceBindingMigration } ` . split (
29+ '--> statement-breakpoint'
30+ ) ) {
2231 if ( statement . trim ( ) ) await sql . unsafe ( statement )
2332 }
2433 }
@@ -168,6 +177,85 @@ describe('file entity ownership migration in PostgreSQL', () => {
168177 } )
169178 } )
170179
180+ it . each ( [ 'workspace' , 'chat' , 'mothership' , 'execution' , 'workspace-logos' ] ) (
181+ 'requires a workspace owner on insert and update for %s files, including archived files' ,
182+ async ( context ) => {
183+ for ( const deletedAt of [ null , new Date ( ) ] ) {
184+ await expect ( sql `INSERT INTO workspace_files (id, context, user_id, deleted_at)
185+ VALUES ('unowned', ${ context } , 'user-a', ${ deletedAt } )` ) . rejects . toMatchObject ( {
186+ code : '23514' ,
187+ } )
188+ }
189+ await expect ( sql `INSERT INTO workspace_files (id, context, user_id, organization_id)
190+ VALUES ('wrong-owner', ${ context } , 'user-a', 'organization-a')` ) . rejects . toMatchObject ( {
191+ code : '23514' ,
192+ } )
193+ await sql `INSERT INTO workspace_files (id, context, user_id, workspace_id)
194+ VALUES ('owned', ${ context } , 'user-a', 'workspace-a')`
195+ await expect (
196+ sql `UPDATE workspace_files SET workspace_id = NULL WHERE id = 'owned'`
197+ ) . rejects . toMatchObject ( { code : '23514' } )
198+ await sql `INSERT INTO workspace_files (id, context, user_id)
199+ VALUES ('personal', 'copilot', 'user-a')`
200+ await expect (
201+ sql `UPDATE workspace_files SET context = ${ context } WHERE id = 'personal'`
202+ ) . rejects . toMatchObject ( { code : '23514' } )
203+ expect ( await sql `SELECT workspace_id FROM workspace_files WHERE id = 'owned'` ) . toEqual ( [
204+ { workspace_id : 'workspace-a' } ,
205+ ] )
206+ }
207+ )
208+
209+ it ( 'keeps personal and both knowledge-base owner types valid' , async ( ) => {
210+ await sql `INSERT INTO workspace_files (id, context, user_id, workspace_id, organization_id)
211+ VALUES ('avatar', 'profile-pictures', 'user-a', NULL, NULL),
212+ ('personal', 'copilot', 'user-a', NULL, NULL),
213+ ('workspace-kb', 'knowledge-base', 'user-a', 'workspace-a', NULL),
214+ ('organization-kb', 'knowledge-base', 'user-a', NULL, 'organization-a')`
215+ expect (
216+ await sql `SELECT file.id, owner.entity_type, owner.entity_id
217+ FROM workspace_files file CROSS JOIN LATERAL workspace_file_owner(
218+ file.context, file.workspace_id, file.project_id, file.organization_id, file.user_id
219+ ) owner ORDER BY file.id`
220+ ) . toEqual ( [
221+ { id : 'avatar' , entity_type : 'user' , entity_id : 'user-a' } ,
222+ { id : 'organization-kb' , entity_type : 'organization' , entity_id : 'organization-a' } ,
223+ { id : 'personal' , entity_type : 'user' , entity_id : 'user-a' } ,
224+ { id : 'workspace-kb' , entity_type : 'workspace' , entity_id : 'workspace-a' } ,
225+ ] )
226+ } )
227+
228+ it ( 'reports retained ownerless files without guessing, then validates after explicit repair and replays safely' , async ( ) => {
229+ await sql `ALTER TABLE workspace_files DROP CONSTRAINT workspace_files_workspace_binding_check`
230+ await sql `INSERT INTO workspace_files (id, context, user_id, deleted_at)
231+ VALUES ('legacy-unowned', 'workspace', 'user-a', now())`
232+ await applyMigration ( )
233+ await applyMigration ( )
234+ await expect ( runScriptMigrations ( sql , [ validateFileWorkspaceBindingMigration ] ) ) . rejects . toThrow (
235+ 'Workspace-scoped files are missing workspace_id'
236+ )
237+ expect ( await sql `SELECT name FROM script_migrations` ) . toEqual ( [ ] )
238+ expect ( await sql `SELECT workspace_id, user_id, key FROM workspace_files` ) . toEqual ( [
239+ { workspace_id : null , user_id : 'user-a' , key : 'unchanged-object-key' } ,
240+ ] )
241+ expect (
242+ await sql `SELECT convalidated FROM pg_constraint
243+ WHERE conrelid = 'workspace_files'::regclass
244+ AND conname = 'workspace_files_workspace_binding_check'`
245+ ) . toEqual ( [ { convalidated : false } ] )
246+ await sql `UPDATE workspace_files SET workspace_id = 'workspace-b' WHERE id = 'legacy-unowned'`
247+ await runScriptMigrations ( sql , [ validateFileWorkspaceBindingMigration ] )
248+ await runScriptMigrations ( sql , [ validateFileWorkspaceBindingMigration ] )
249+ expect ( await sql `SELECT name FROM script_migrations` ) . toEqual ( [
250+ { name : '0034_validate_file_workspace_binding' } ,
251+ ] )
252+ expect (
253+ await sql `SELECT convalidated FROM pg_constraint
254+ WHERE conrelid = 'workspace_files'::regclass
255+ AND conname = 'workspace_files_workspace_binding_check'`
256+ ) . toEqual ( [ { convalidated : true } ] )
257+ } )
258+
171259 it ( 'allows independent concurrent file inserts into the same Project' , async ( ) => {
172260 const inserted = createDeferred < void > ( )
173261 const release = createDeferred < void > ( )
0 commit comments