@@ -54,6 +54,7 @@ import {
5454 getExecutionEnvironment ,
5555 getPersonalAndWorkspaceEnv ,
5656 invalidateEffectiveDecryptedEnvCache ,
57+ resolveEffectiveEnvironmentVariables ,
5758 upsertWorkspaceEnvVars ,
5859 WorkspaceEnvAccessError ,
5960} from '@/lib/environment/utils'
@@ -175,6 +176,172 @@ describe('getEffectiveEnvironmentVariableNames', () => {
175176 } )
176177} )
177178
179+ describe ( 'resolveEffectiveEnvironmentVariables' , ( ) => {
180+ beforeEach ( ( ) => {
181+ vi . clearAllMocks ( )
182+ resetDbChainMock ( )
183+ invalidateEffectiveDecryptedEnvCache ( { userId : 'resolver-user' } )
184+ mockCheckWorkspaceAccess . mockResolvedValue ( {
185+ exists : true ,
186+ hasAccess : true ,
187+ canWrite : true ,
188+ canAdmin : false ,
189+ } )
190+ mockGetAccessibleEnvCredentials . mockResolvedValue ( [ ] )
191+ encryptionMockFns . mockDecryptSecret . mockReset ( )
192+ } )
193+
194+ it ( 'decrypts only unique requested accessible values with workspace precedence' , async ( ) => {
195+ mockGetAccessibleEnvCredentials . mockResolvedValue ( [
196+ {
197+ type : 'env_workspace' ,
198+ envKey : 'VISIBLE_SHARED' ,
199+ envOwnerUserId : null ,
200+ updatedAt : new Date ( '2026-01-01T00:00:00.000Z' ) ,
201+ unredacted : true ,
202+ } ,
203+ {
204+ type : 'env_workspace' ,
205+ envKey : 'HIDDEN_SHARED' ,
206+ envOwnerUserId : null ,
207+ updatedAt : new Date ( '2026-01-01T00:00:00.000Z' ) ,
208+ unredacted : false ,
209+ } ,
210+ {
211+ type : 'env_workspace' ,
212+ envKey : 'DUPLICATE' ,
213+ envOwnerUserId : null ,
214+ updatedAt : new Date ( '2026-01-01T00:00:00.000Z' ) ,
215+ unredacted : false ,
216+ } ,
217+ {
218+ type : 'env_workspace' ,
219+ envKey : 'BROKEN' ,
220+ envOwnerUserId : null ,
221+ updatedAt : new Date ( '2026-01-01T00:00:00.000Z' ) ,
222+ unredacted : false ,
223+ } ,
224+ {
225+ type : 'env_personal' ,
226+ envKey : 'SHARED_PERSONAL' ,
227+ envOwnerUserId : 'owner-2' ,
228+ updatedAt : new Date ( '2026-01-01T00:00:00.000Z' ) ,
229+ } ,
230+ ] )
231+ queueTableRows ( environment , [
232+ {
233+ variables : {
234+ OWN_PERSONAL : 'own-cipher' ,
235+ DUPLICATE : 'personal-shadow-cipher' ,
236+ UNREQUESTED_PERSONAL : 'unrequested-personal-cipher' ,
237+ } ,
238+ } ,
239+ ] )
240+ queueTableRows ( workspaceEnvironment , [
241+ {
242+ variables : {
243+ VISIBLE_SHARED : 'visible-cipher' ,
244+ HIDDEN_SHARED : 'hidden-cipher' ,
245+ DUPLICATE : 'workspace-cipher' ,
246+ BROKEN : 'broken-cipher' ,
247+ INACCESSIBLE : 'inaccessible-cipher' ,
248+ UNREQUESTED_WORKSPACE : 'unrequested-workspace-cipher' ,
249+ } ,
250+ } ,
251+ ] )
252+ queueTableRows ( environment , [
253+ { userId : 'owner-2' , variables : { SHARED_PERSONAL : 'shared-personal-cipher' } } ,
254+ ] )
255+ encryptionMockFns . mockDecryptSecret . mockImplementation ( async ( encryptedValue : string ) => {
256+ if ( encryptedValue === 'broken-cipher' ) throw new Error ( 'cannot decrypt' )
257+ return { decrypted : `plain:${ encryptedValue } ` }
258+ } )
259+
260+ await expect (
261+ resolveEffectiveEnvironmentVariables ( 'resolver-user' , 'workspace-1' , [
262+ 'OWN_PERSONAL' ,
263+ 'SHARED_PERSONAL' ,
264+ 'VISIBLE_SHARED' ,
265+ 'HIDDEN_SHARED' ,
266+ 'DUPLICATE' ,
267+ 'DUPLICATE' ,
268+ 'BROKEN' ,
269+ 'MISSING' ,
270+ 'INACCESSIBLE' ,
271+ 'constructor' ,
272+ ] )
273+ ) . resolves . toEqual ( {
274+ OWN_PERSONAL : {
275+ value : 'plain:own-cipher' ,
276+ scope : 'personal' ,
277+ visible : true ,
278+ } ,
279+ SHARED_PERSONAL : {
280+ value : 'plain:shared-personal-cipher' ,
281+ scope : 'personal' ,
282+ visible : false ,
283+ } ,
284+ VISIBLE_SHARED : {
285+ value : 'plain:visible-cipher' ,
286+ scope : 'workspace' ,
287+ visible : true ,
288+ } ,
289+ HIDDEN_SHARED : {
290+ value : 'plain:hidden-cipher' ,
291+ scope : 'workspace' ,
292+ visible : false ,
293+ } ,
294+ DUPLICATE : {
295+ value : 'plain:workspace-cipher' ,
296+ scope : 'workspace' ,
297+ visible : false ,
298+ } ,
299+ } )
300+ expect ( encryptionMockFns . mockDecryptSecret . mock . calls . map ( ( [ value ] ) => value ) ) . toEqual ( [
301+ 'own-cipher' ,
302+ 'shared-personal-cipher' ,
303+ 'visible-cipher' ,
304+ 'hidden-cipher' ,
305+ 'workspace-cipher' ,
306+ 'broken-cipher' ,
307+ ] )
308+ } )
309+
310+ it ( 'performs a fresh lookup without reading or warming the snapshot cache' , async ( ) => {
311+ encryptionMockFns . mockDecryptSecret . mockImplementation ( async ( encryptedValue : string ) => ( {
312+ decrypted : `plain:${ encryptedValue } ` ,
313+ } ) )
314+
315+ queueTableRows ( environment , [ { variables : { ROTATING : 'first-cipher' } } ] )
316+ queueTableRows ( workspaceEnvironment , [ { variables : { } } ] )
317+ await expect (
318+ resolveEffectiveEnvironmentVariables ( 'resolver-user' , 'workspace-1' , [ 'ROTATING' ] )
319+ ) . resolves . toEqual ( {
320+ ROTATING : { value : 'plain:first-cipher' , scope : 'personal' , visible : true } ,
321+ } )
322+
323+ queueTableRows ( environment , [ { variables : { ROTATING : 'snapshot-cipher' } } ] )
324+ queueTableRows ( workspaceEnvironment , [ { variables : { } } ] )
325+ await expect (
326+ getEffectiveEnvironmentSnapshot ( 'resolver-user' , 'workspace-1' )
327+ ) . resolves . toMatchObject ( { personalDecrypted : { ROTATING : 'plain:snapshot-cipher' } } )
328+
329+ queueTableRows ( environment , [ { variables : { ROTATING : 'fresh-cipher' } } ] )
330+ queueTableRows ( workspaceEnvironment , [ { variables : { } } ] )
331+ await expect (
332+ resolveEffectiveEnvironmentVariables ( 'resolver-user' , 'workspace-1' , [ 'ROTATING' ] )
333+ ) . resolves . toEqual ( {
334+ ROTATING : { value : 'plain:fresh-cipher' , scope : 'personal' , visible : true } ,
335+ } )
336+
337+ await expect (
338+ getEffectiveEnvironmentSnapshot ( 'resolver-user' , 'workspace-1' )
339+ ) . resolves . toMatchObject ( { personalDecrypted : { ROTATING : 'plain:snapshot-cipher' } } )
340+ expect ( encryptionMockFns . mockDecryptSecret ) . toHaveBeenCalledTimes ( 3 )
341+ expect ( mockCheckWorkspaceAccess ) . toHaveBeenCalledTimes ( 3 )
342+ } )
343+ } )
344+
178345describe ( 'getPersonalAndWorkspaceEnv access filtering' , ( ) => {
179346 beforeEach ( ( ) => {
180347 vi . clearAllMocks ( )
0 commit comments