@@ -13,8 +13,17 @@ import {
1313 ROLE_ALLOWED_OPERATIONS ,
1414 SOCKET_OPERATIONS ,
1515} from '@sim/testing'
16- import { describe , expect , it } from 'vitest'
17- import { checkRolePermission } from '@/middleware/permissions'
16+ import { beforeEach , describe , expect , it , vi } from 'vitest'
17+
18+ const { mockAuthorize } = vi . hoisted ( ( ) => ( {
19+ mockAuthorize : vi . fn ( ) ,
20+ } ) )
21+
22+ vi . mock ( '@sim/workflow-authz' , ( ) => ( {
23+ authorizeWorkflowByWorkspacePermission : mockAuthorize ,
24+ } ) )
25+
26+ import { checkRolePermission , checkWorkflowOperationPermission } from '@/middleware/permissions'
1827
1928describe ( 'checkRolePermission' , ( ) => {
2029 describe ( 'admin role' , ( ) => {
@@ -279,3 +288,89 @@ describe('checkRolePermission', () => {
279288 } )
280289 } )
281290} )
291+
292+ describe ( 'checkWorkflowOperationPermission' , ( ) => {
293+ const userId = 'user-1'
294+ let workflowCounter = 0
295+ let workflowId : string
296+
297+ beforeEach ( ( ) => {
298+ vi . clearAllMocks ( )
299+ // Unique workflowId per test so the module-level role cache never leaks across tests
300+ workflowCounter += 1
301+ workflowId = `wf-${ workflowCounter } `
302+ } )
303+
304+ it ( 'allows a write operation when the user still has write access' , async ( ) => {
305+ mockAuthorize . mockResolvedValue ( { allowed : true , workspacePermission : 'write' } )
306+
307+ const result = await checkWorkflowOperationPermission ( userId , workflowId , 'update' , 'read' )
308+
309+ expect ( result . allowed ) . toBe ( true )
310+ expect ( result . role ) . toBe ( 'write' )
311+ } )
312+
313+ it ( 'denies all writes once workspace access has been revoked' , async ( ) => {
314+ mockAuthorize . mockResolvedValue ( { allowed : false , workspacePermission : null } )
315+
316+ const result = await checkWorkflowOperationPermission ( userId , workflowId , 'update' , 'write' )
317+
318+ expect ( result . allowed ) . toBe ( false )
319+ expect ( result . role ) . toBeNull ( )
320+ expect ( result . reason ) . toMatch ( / r e v o k e d / i)
321+ } )
322+
323+ it ( 'denies writes after a downgrade to read but still allows position updates' , async ( ) => {
324+ mockAuthorize . mockResolvedValue ( { allowed : true , workspacePermission : 'read' } )
325+
326+ const denied = await checkWorkflowOperationPermission ( userId , workflowId , 'update' , 'write' )
327+ expect ( denied . allowed ) . toBe ( false )
328+ expect ( denied . role ) . toBe ( 'read' )
329+
330+ const allowed = await checkWorkflowOperationPermission (
331+ userId ,
332+ workflowId ,
333+ 'update-position' ,
334+ 'write'
335+ )
336+ expect ( allowed . allowed ) . toBe ( true )
337+ expect ( allowed . role ) . toBe ( 'read' )
338+ } )
339+
340+ it ( 'caches the role within the TTL to avoid a DB read on every operation' , async ( ) => {
341+ mockAuthorize . mockResolvedValue ( { allowed : true , workspacePermission : 'write' } )
342+
343+ await checkWorkflowOperationPermission ( userId , workflowId , 'update' , 'read' )
344+ await checkWorkflowOperationPermission ( userId , workflowId , 'update' , 'read' )
345+
346+ expect ( mockAuthorize ) . toHaveBeenCalledTimes ( 1 )
347+ } )
348+
349+ it ( 're-reads the role after the cache TTL expires' , async ( ) => {
350+ vi . useFakeTimers ( )
351+ try {
352+ mockAuthorize . mockResolvedValue ( { allowed : true , workspacePermission : 'write' } )
353+ await checkWorkflowOperationPermission ( userId , workflowId , 'update' , 'read' )
354+
355+ // Downgraded to read after the first check
356+ mockAuthorize . mockResolvedValue ( { allowed : true , workspacePermission : 'read' } )
357+ vi . advanceTimersByTime ( 31_000 )
358+
359+ const result = await checkWorkflowOperationPermission ( userId , workflowId , 'update' , 'write' )
360+ expect ( mockAuthorize ) . toHaveBeenCalledTimes ( 2 )
361+ expect ( result . allowed ) . toBe ( false )
362+ expect ( result . role ) . toBe ( 'read' )
363+ } finally {
364+ vi . useRealTimers ( )
365+ }
366+ } )
367+
368+ it ( 'falls back to the last known role on a transient DB error' , async ( ) => {
369+ mockAuthorize . mockRejectedValue ( new Error ( 'db unavailable' ) )
370+
371+ const result = await checkWorkflowOperationPermission ( userId , workflowId , 'update' , 'write' )
372+
373+ expect ( result . allowed ) . toBe ( true )
374+ expect ( result . role ) . toBe ( 'write' )
375+ } )
376+ } )
0 commit comments