@@ -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,129 @@ 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 join-time role on a transient DB error when nothing is cached yet' , 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+
377+ it ( 'preserves a recorded revocation through a later transient DB error' , async ( ) => {
378+ vi . useFakeTimers ( )
379+ try {
380+ // First check records the revocation (null) in the cache
381+ mockAuthorize . mockResolvedValue ( { allowed : false , workspacePermission : null } )
382+ const first = await checkWorkflowOperationPermission ( userId , workflowId , 'update' , 'admin' )
383+ expect ( first . allowed ) . toBe ( false )
384+ expect ( first . role ) . toBeNull ( )
385+
386+ // TTL expires, then the DB blips on the next re-validation. The stale join-time
387+ // role ('admin') must NOT resurrect access — the recorded revocation wins.
388+ vi . advanceTimersByTime ( 31_000 )
389+ mockAuthorize . mockRejectedValue ( new Error ( 'db unavailable' ) )
390+
391+ const second = await checkWorkflowOperationPermission ( userId , workflowId , 'update' , 'admin' )
392+ expect ( second . allowed ) . toBe ( false )
393+ expect ( second . role ) . toBeNull ( )
394+ } finally {
395+ vi . useRealTimers ( )
396+ }
397+ } )
398+
399+ it ( 'uses the last cached role (not the join-time role) on a transient DB error' , async ( ) => {
400+ vi . useFakeTimers ( )
401+ try {
402+ mockAuthorize . mockResolvedValue ( { allowed : true , workspacePermission : 'write' } )
403+ await checkWorkflowOperationPermission ( userId , workflowId , 'update' , 'read' )
404+
405+ vi . advanceTimersByTime ( 31_000 )
406+ mockAuthorize . mockRejectedValue ( new Error ( 'db unavailable' ) )
407+
408+ // fallbackRole is 'read', but the last recorded decision was 'write' — use that
409+ const result = await checkWorkflowOperationPermission ( userId , workflowId , 'update' , 'read' )
410+ expect ( result . allowed ) . toBe ( true )
411+ expect ( result . role ) . toBe ( 'write' )
412+ } finally {
413+ vi . useRealTimers ( )
414+ }
415+ } )
416+ } )
0 commit comments