11/**
22 * @vitest -environment node
33 */
4+ import { DrizzleQueryError } from 'drizzle-orm/errors'
45import { beforeEach , describe , expect , it , vi } from 'vitest'
56
67const mocks = vi . hoisted ( ( ) => ( {
@@ -18,6 +19,7 @@ vi.mock('@/lib/billing/core/subscription', () => ({
1819 isOrganizationOnEnterprisePlan : vi . fn ( ) ,
1920} ) )
2021vi . mock ( '@/lib/workspaces/permissions/utils' , ( ) => ( { getWorkspaceWithOwner : vi . fn ( ) } ) )
22+ vi . mock ( '@sim/utils/helpers' , ( ) => ( { sleep : vi . fn ( ) . mockResolvedValue ( undefined ) } ) )
2123vi . mock ( '@/providers/utils' , ( ) => ( {
2224 isFunctionToolCall : ( ) => false ,
2325 getProviderFromModel : ( ) => 'openai' ,
@@ -36,7 +38,10 @@ import {
3638 * field and keeps gating on the caller.
3739 */
3840function runDeclaring ( capabilityGovernedUserId ?: string | null ) : ExecutionContext {
39- return { metadata : { capabilityGovernedUserId } } as unknown as ExecutionContext
41+ return {
42+ metadata : { capabilityGovernedUserId } ,
43+ permissionConfigCache : new Map ( ) ,
44+ } as unknown as ExecutionContext
4045}
4146
4247describe ( 'the subject a run’s permission gate is decided about' , ( ) => {
@@ -164,3 +169,183 @@ describe('the group a run’s later gates read from its cache', () => {
164169 expect ( mocks . getUserPermissionConfig ) . not . toHaveBeenCalled ( )
165170 } )
166171} )
172+
173+ function databaseError ( code = 'ECONNRESET' ) : DrizzleQueryError {
174+ return new DrizzleQueryError (
175+ 'select "billing_blocked" from "user_stats" where "user_stats"."user_id" = $1' ,
176+ [ 'owner-secret-id' ] ,
177+ Object . assign ( new Error ( `driver failure ${ code } ` ) , { code } )
178+ )
179+ }
180+
181+ /** Every block runs on a shallow copy of the run's context, so the memo lives in a Map they share. */
182+ describe ( 'the run-scoped permission config cache' , ( ) => {
183+ function runContext ( overrides : Partial < ExecutionContext > = { } ) : ExecutionContext {
184+ return {
185+ metadata : { } ,
186+ permissionConfigCache : new Map ( ) ,
187+ ...overrides ,
188+ } as unknown as ExecutionContext
189+ }
190+
191+ function gate ( ctx : ExecutionContext , workspaceId = 'workspace-1' ) {
192+ return assertPermissionsAllowed ( {
193+ userId : 'user-1' ,
194+ workspaceId,
195+ toolId : 'http_request' ,
196+ ctx,
197+ } )
198+ }
199+
200+ beforeEach ( ( ) => {
201+ vi . clearAllMocks ( )
202+ mocks . getUserPermissionConfig . mockResolvedValue ( { deniedTools : [ ] } )
203+ } )
204+
205+ it ( 'loads once across the per-block copies of one run' , async ( ) => {
206+ const run = runContext ( )
207+
208+ await gate ( { ...run } )
209+ await gate ( { ...run } )
210+
211+ expect ( mocks . getUserPermissionConfig ) . toHaveBeenCalledExactlyOnceWith ( 'user-1' , 'workspace-1' )
212+ } )
213+
214+ it ( 'shares one in-flight load between concurrent parallel branches' , async ( ) => {
215+ const run = runContext ( )
216+ let release ! : ( config : unknown ) => void
217+ mocks . getUserPermissionConfig . mockReturnValueOnce (
218+ new Promise ( ( resolve ) => {
219+ release = resolve
220+ } )
221+ )
222+
223+ const branches = Promise . all ( Array . from ( { length : 5 } , ( ) => gate ( { ...run } ) ) )
224+ release ( { deniedTools : [ ] } )
225+ await branches
226+
227+ expect ( mocks . getUserPermissionConfig ) . toHaveBeenCalledTimes ( 1 )
228+ } )
229+
230+ it ( 'keeps a separate entry per workspace' , async ( ) => {
231+ const run = runContext ( )
232+ mocks . getUserPermissionConfig . mockImplementation ( async ( _userId , workspaceId ) =>
233+ workspaceId === 'workspace-2' ? { deniedTools : [ 'http_request' ] } : { deniedTools : [ ] }
234+ )
235+
236+ await gate ( { ...run } , 'workspace-1' )
237+ await expect ( gate ( { ...run } , 'workspace-2' ) ) . rejects . toBeInstanceOf ( ToolNotAllowedError )
238+
239+ expect ( mocks . getUserPermissionConfig ) . toHaveBeenCalledTimes ( 2 )
240+ } )
241+
242+ it ( 'evicts a failed load so a later gate loads again' , async ( ) => {
243+ const run = runContext ( )
244+ mocks . getUserPermissionConfig . mockRejectedValueOnce ( new Error ( 'config unavailable' ) )
245+
246+ await expect ( gate ( { ...run } ) ) . rejects . toThrow ( 'config unavailable' )
247+ await gate ( { ...run } )
248+
249+ expect ( mocks . getUserPermissionConfig ) . toHaveBeenCalledTimes ( 2 )
250+ } )
251+
252+ it ( 'retries a transient database failure and then caches the result' , async ( ) => {
253+ const run = runContext ( )
254+ mocks . getUserPermissionConfig . mockRejectedValueOnce ( databaseError ( ) )
255+
256+ await gate ( { ...run } )
257+ await gate ( { ...run } )
258+
259+ expect ( mocks . getUserPermissionConfig ) . toHaveBeenCalledTimes ( 2 )
260+ } )
261+
262+ it ( 'does not retry a database failure that is not transient' , async ( ) => {
263+ const sqlError = databaseError ( '42703' )
264+ mocks . getUserPermissionConfig . mockRejectedValue ( sqlError )
265+
266+ await expect ( gate ( runContext ( ) ) ) . rejects . toBe ( sqlError )
267+ expect ( mocks . getUserPermissionConfig ) . toHaveBeenCalledTimes ( 1 )
268+ } )
269+
270+ it ( 'fails closed with the last error once retries are exhausted' , async ( ) => {
271+ const error = databaseError ( )
272+ mocks . getUserPermissionConfig . mockRejectedValue ( error )
273+
274+ await expect ( gate ( runContext ( ) ) ) . rejects . toBe ( error )
275+ expect ( mocks . getUserPermissionConfig ) . toHaveBeenCalledTimes ( 3 )
276+ } )
277+
278+ it ( 'stops retrying when the run is cancelled' , async ( ) => {
279+ const controller = new AbortController ( )
280+ const reason = new Error ( 'Execution cancelled' )
281+ mocks . getUserPermissionConfig . mockImplementationOnce ( async ( ) => {
282+ controller . abort ( reason )
283+ throw databaseError ( )
284+ } )
285+
286+ await expect ( gate ( runContext ( { abortSignal : controller . signal } ) ) ) . rejects . toBe ( reason )
287+ expect ( mocks . getUserPermissionConfig ) . toHaveBeenCalledTimes ( 1 )
288+ } )
289+
290+ it ( 'does not memoize on a context that carries no run cache' , async ( ) => {
291+ const ctx = { metadata : { } } as unknown as ExecutionContext
292+
293+ await gate ( ctx )
294+ await gate ( ctx )
295+
296+ expect ( mocks . getUserPermissionConfig ) . toHaveBeenCalledTimes ( 2 )
297+ expect ( ctx . permissionConfigCache ) . toBeUndefined ( )
298+ } )
299+
300+ it ( 'stops retrying when the caller of a check outside a run cancels' , async ( ) => {
301+ const controller = new AbortController ( )
302+ const reason = new Error ( 'Tool cancelled' )
303+ mocks . getUserPermissionConfig . mockImplementationOnce ( async ( ) => {
304+ controller . abort ( reason )
305+ throw databaseError ( )
306+ } )
307+
308+ await expect (
309+ assertPermissionsAllowed ( {
310+ userId : 'user-1' ,
311+ workspaceId : 'workspace-1' ,
312+ toolId : 'http_request' ,
313+ signal : controller . signal ,
314+ } )
315+ ) . rejects . toBe ( reason )
316+ expect ( mocks . getUserPermissionConfig ) . toHaveBeenCalledTimes ( 1 )
317+ } )
318+
319+ it ( 'does not let one caller cancel a load shared through the run cache' , async ( ) => {
320+ const run = runContext ( )
321+ const controller = new AbortController ( )
322+ mocks . getUserPermissionConfig . mockImplementationOnce ( async ( ) => {
323+ controller . abort ( new Error ( 'Tool cancelled' ) )
324+ throw databaseError ( )
325+ } )
326+
327+ const cancelled = assertPermissionsAllowed ( {
328+ userId : 'user-1' ,
329+ workspaceId : 'workspace-1' ,
330+ toolId : 'http_request' ,
331+ ctx : { ...run } ,
332+ signal : controller . signal ,
333+ } )
334+ const other = gate ( { ...run } )
335+
336+ await expect ( Promise . all ( [ cancelled , other ] ) ) . resolves . toBeDefined ( )
337+ expect ( mocks . getUserPermissionConfig ) . toHaveBeenCalledTimes ( 2 )
338+ } )
339+
340+ it ( 'retries a transient failure for a check made outside a run' , async ( ) => {
341+ mocks . getUserPermissionConfig . mockRejectedValueOnce ( databaseError ( ) )
342+
343+ await assertPermissionsAllowed ( {
344+ userId : 'user-1' ,
345+ workspaceId : 'workspace-1' ,
346+ toolId : 'http_request' ,
347+ } )
348+
349+ expect ( mocks . getUserPermissionConfig ) . toHaveBeenCalledTimes ( 2 )
350+ } )
351+ } )
0 commit comments