@@ -11,6 +11,7 @@ import {
1111 user ,
1212 userStats ,
1313 workflow ,
14+ workflowBlocks ,
1415 workspace ,
1516 workspaceForkBlockMap ,
1617 workspaceForkDependentValue ,
@@ -21,14 +22,15 @@ import {
2122 createSessionPrincipal ,
2223 createWorkspaceApiKeyPrincipal ,
2324} from '@sim/testing/factories/principal.factory'
24- import { getErrorMessage } from '@sim/utils/errors'
25+ import { getErrorMessage , getPostgresErrorCode } from '@sim/utils/errors'
2526import { generateId } from '@sim/utils/id'
26- import { and , eq , inArray } from 'drizzle-orm'
27+ import { and , eq , inArray , sql } from 'drizzle-orm'
2728import { afterAll , describe , expect , it } from 'vitest'
2829import { removeUserFromOrganization } from '@/lib/billing/organizations/membership'
2930import { prepareProjectsForAccountDeletion } from '@/lib/projects/account-deletion'
3031import {
3132 archiveProject ,
33+ createProject ,
3234 getProject ,
3335 getProjectIssueAccess ,
3436 getWorkspaceProject ,
@@ -180,6 +182,172 @@ afterAll(async () => {
180182} )
181183
182184describe ( 'Project foundation at the database and application boundary' , ( ) => {
185+ check (
186+ 'Project creation commits its named first environment and starter workflow in the requested scope' ,
187+ async ( ) => {
188+ for ( const personal of [ false , true ] ) {
189+ const f = await fixture ( true , 1 )
190+ const organizationId = personal ? null : f . organizationId
191+ if ( ! f . organizationId ) throw new Error ( 'Missing organization fixture' )
192+ await db . insert ( member ) . values ( {
193+ id : generateId ( ) ,
194+ organizationId : f . organizationId ,
195+ userId : f . teammateId ,
196+ role : 'admin' ,
197+ createdAt : new Date ( ) ,
198+ } )
199+ const result = await createProject . execute ( {
200+ principal : f . teammate ,
201+ input : {
202+ organizationId,
203+ name : 'Customer support' ,
204+ initialEnvironment : { name : 'Production' } ,
205+ } ,
206+ request,
207+ } )
208+ environments . push ( result . initialEnvironment . id )
209+ const [ created ] = await db . select ( ) . from ( project ) . where ( eq ( project . id , result . project . id ) )
210+ expect ( created ) . toMatchObject ( {
211+ name : 'Customer support' ,
212+ organizationId,
213+ ownerId : f . teammateId ,
214+ } )
215+ const [ env ] = await db
216+ . select ( )
217+ . from ( workspace )
218+ . where ( eq ( workspace . id , result . initialEnvironment . id ) )
219+ expect ( env ) . toMatchObject ( {
220+ name : 'Production' ,
221+ organizationId,
222+ ownerId : f . teammateId ,
223+ billedAccountUserId : personal ? f . teammateId : f . ownerId ,
224+ } )
225+ expect (
226+ await db
227+ . select ( {
228+ projectId : projectWorkspace . projectId ,
229+ workspaceId : projectWorkspace . workspaceId ,
230+ } )
231+ . from ( projectWorkspace )
232+ . where ( eq ( projectWorkspace . projectId , created . id ) )
233+ ) . toEqual ( [ { projectId : created . id , workspaceId : env . id } ] )
234+ const grants = await db . select ( ) . from ( permissions ) . where ( eq ( permissions . entityId , env . id ) )
235+ expect (
236+ grants . map ( ( grant ) => ( { userId : grant . userId , permissionType : grant . permissionType } ) )
237+ ) . toEqual (
238+ expect . arrayContaining ( [
239+ { userId : f . teammateId , permissionType : 'admin' } ,
240+ ...( personal ? [ ] : [ { userId : f . ownerId , permissionType : 'admin' } ] ) ,
241+ ] )
242+ )
243+ expect ( grants ) . toHaveLength ( personal ? 1 : 2 )
244+ const workflows = await db . select ( ) . from ( workflow ) . where ( eq ( workflow . workspaceId , env . id ) )
245+ expect ( workflows ) . toHaveLength ( 1 )
246+ const blocks = await db
247+ . select ( )
248+ . from ( workflowBlocks )
249+ . where ( eq ( workflowBlocks . workflowId , workflows [ 0 ] . id ) )
250+ expect ( blocks . length ) . toBeGreaterThan ( 0 )
251+ }
252+ }
253+ )
254+
255+ check (
256+ 'Project creation refuses workspace keys and foreign organizations without creating resources' ,
257+ async ( ) => {
258+ const f = await fixture ( true , 1 )
259+ const foreign = await fixture ( true , 1 )
260+ const input = {
261+ organizationId : foreign . organizationId ,
262+ name : 'Forbidden project' ,
263+ initialEnvironment : { name : 'Production' } ,
264+ }
265+ await expect (
266+ createProject . execute ( { principal : f . owner , input, request } )
267+ ) . rejects . toMatchObject ( { code : 'forbidden' } )
268+ await expect (
269+ createProject . execute ( {
270+ principal : createWorkspaceApiKeyPrincipal ( { workspaceId : f . ids [ 0 ] } ) ,
271+ input : { ...input , organizationId : f . organizationId } ,
272+ request,
273+ } )
274+ ) . rejects . toThrow ( 'cannot perform operation' )
275+ expect ( await db . select ( ) . from ( project ) . where ( eq ( project . ownerId , f . ownerId ) ) ) . toHaveLength ( 1 )
276+ expect (
277+ await db . select ( ) . from ( workspace ) . where ( eq ( workspace . ownerId , f . ownerId ) )
278+ ) . toHaveLength ( 1 )
279+ }
280+ )
281+
282+ check (
283+ 'Project creation cannot bypass the workspace-creation restriction through personal scope' ,
284+ async ( ) => {
285+ const f = await fixture ( true , 1 )
286+ if ( ! f . organizationId ) throw new Error ( 'Missing organization fixture' )
287+ await db . insert ( permissionGroup ) . values ( {
288+ id : generateId ( ) ,
289+ organizationId : f . organizationId ,
290+ name : 'Creation disabled' ,
291+ createdBy : f . ownerId ,
292+ isDefault : true ,
293+ config : { disableWorkspaceCreation : true } ,
294+ } )
295+ for ( const organizationId of [ f . organizationId , null ] ) {
296+ await expect (
297+ createProject . execute ( {
298+ principal : f . owner ,
299+ input : {
300+ organizationId,
301+ name : 'Forbidden project' ,
302+ initialEnvironment : { name : 'Production' } ,
303+ } ,
304+ request,
305+ } )
306+ ) . rejects . toMatchObject ( { detailCode : 'PERMISSION_GROUP_CAPABILITY_BLOCKED' } )
307+ }
308+ expect ( await db . select ( ) . from ( project ) . where ( eq ( project . ownerId , f . ownerId ) ) ) . toHaveLength ( 1 )
309+ expect (
310+ await db . select ( ) . from ( workspace ) . where ( eq ( workspace . ownerId , f . ownerId ) )
311+ ) . toHaveLength ( 1 )
312+ }
313+ )
314+
315+ check (
316+ 'A starter-workflow failure rolls back the Project, environment and permissions together' ,
317+ async ( ) => {
318+ const f = await fixture ( false , 1 )
319+ const constraint = sql . identifier ( `project_create_test_${ generateId ( ) . replaceAll ( '-' , '' ) } ` )
320+ const before = await db . select ( ) . from ( permissions ) . where ( eq ( permissions . userId , f . ownerId ) )
321+ await db . execute (
322+ sql `ALTER TABLE ${ workflow } ADD CONSTRAINT ${ constraint } CHECK (${ workflow . userId } <> ${ f . ownerId } ) NOT VALID` . inlineParams ( )
323+ )
324+ try {
325+ await expect (
326+ createProject . execute ( {
327+ principal : f . owner ,
328+ input : {
329+ organizationId : null ,
330+ name : 'Rollback project' ,
331+ initialEnvironment : { name : 'Rollback environment' } ,
332+ } ,
333+ request,
334+ } )
335+ ) . rejects . toSatisfy ( ( error : unknown ) => getPostgresErrorCode ( error ) === '23514' )
336+ expect ( await db . select ( ) . from ( project ) . where ( eq ( project . ownerId , f . ownerId ) ) ) . toHaveLength (
337+ 1
338+ )
339+ expect (
340+ await db . select ( ) . from ( workspace ) . where ( eq ( workspace . ownerId , f . ownerId ) )
341+ ) . toHaveLength ( 1 )
342+ expect (
343+ await db . select ( ) . from ( permissions ) . where ( eq ( permissions . userId , f . ownerId ) )
344+ ) . toEqual ( before )
345+ } finally {
346+ await db . execute ( sql `ALTER TABLE ${ workflow } DROP CONSTRAINT ${ constraint } ` )
347+ }
348+ }
349+ )
350+
183351 check ( 'workspace creation and forks commit exactly one Project membership' , async ( ) => {
184352 const f = await fixture ( false , 1 )
185353 const source = await db . transaction ( ( tx ) =>
0 commit comments