11/**
22 * @vitest -environment node
33 */
4- import { credential , permissions , workspace } from '@sim/db/schema'
5- import { dbChainMock , dbChainMockFns , queueTableRows , resetDbChainMock } from '@sim/testing'
4+ import { credential , environment , permissions , workspace } from '@sim/db/schema'
5+ import {
6+ dbChainMock ,
7+ dbChainMockFns ,
8+ flattenMockConditions ,
9+ queueTableRows ,
10+ resetDbChainMock ,
11+ } from '@sim/testing'
612import { eq } from 'drizzle-orm'
713import { beforeEach , describe , expect , it , vi } from 'vitest'
814import type { DbOrTx } from '@/lib/db/types'
915
10- const { mockAcquireUserBillingIdentityLock } = vi . hoisted ( ( ) => ( {
16+ const { mockAcquireUserBillingIdentityLock, mockLockPersonalEnvMap } = vi . hoisted ( ( ) => ( {
1117 mockAcquireUserBillingIdentityLock : vi . fn ( ) ,
18+ mockLockPersonalEnvMap : vi . fn ( ) ,
1219} ) )
1320
1421vi . mock ( '@/lib/billing/organizations/billing-identity-lock' , ( ) => ( {
1522 acquireUserBillingIdentityLock : mockAcquireUserBillingIdentityLock ,
1623} ) )
1724
25+ vi . mock ( '@/lib/credentials/env-locks' , ( ) => ( {
26+ lockPersonalEnvMap : mockLockPersonalEnvMap ,
27+ } ) )
28+
1829import {
1930 createWorkspaceEnvCredentials ,
2031 getEnrolledManagedOAuthCredentials ,
@@ -196,30 +207,35 @@ describe('syncPersonalEnvCredentialsForUser', () => {
196207 vi . clearAllMocks ( )
197208 resetDbChainMock ( )
198209 mockAcquireUserBillingIdentityLock . mockResolvedValue ( undefined )
210+ mockLockPersonalEnvMap . mockResolvedValue ( undefined )
199211 } )
200212
201- it ( 'uses one transaction and acquires the transfer fence before discovering workspaces ' , async ( ) => {
213+ it ( 'locks the map before the transfer fence and reads current keys before reconciling ' , async ( ) => {
202214 const base = dbChainMock . db
203215 const tx = {
204216 select : vi . fn ( base . select ) ,
205217 insert : vi . fn ( base . insert ) ,
206218 delete : vi . fn ( base . delete ) ,
207219 } as unknown as DbOrTx
208220 dbChainMockFns . transaction . mockImplementationOnce ( async ( callback ) => callback ( tx ) )
221+ queueTableRows ( environment , [ { variables : { API_KEY : 'encrypted' } } ] )
209222 queueTableRows ( permissions , [ { workspaceId : 'ws-1' } ] )
210223 queueTableRows ( workspace , [ ] )
211224 queueTableRows ( credential , [ { id : 'credential-1' } ] )
212225
213226 await syncPersonalEnvCredentialsForUser ( {
214227 userId : 'user-1' ,
215- envKeys : [ 'API_KEY' ] ,
216228 } )
217229
230+ expect ( mockLockPersonalEnvMap ) . toHaveBeenCalledWith ( tx , 'user-1' )
231+ expect ( mockLockPersonalEnvMap . mock . invocationCallOrder [ 0 ] ) . toBeLessThan (
232+ mockAcquireUserBillingIdentityLock . mock . invocationCallOrder [ 0 ]
233+ )
218234 expect ( mockAcquireUserBillingIdentityLock ) . toHaveBeenCalledWith ( tx , 'user-1' )
219235 expect ( mockAcquireUserBillingIdentityLock . mock . invocationCallOrder [ 0 ] ) . toBeLessThan (
220236 ( tx . select as ReturnType < typeof vi . fn > ) . mock . invocationCallOrder [ 0 ]
221237 )
222- expect ( tx . select ) . toHaveBeenCalledTimes ( 3 )
238+ expect ( tx . select ) . toHaveBeenCalledTimes ( 4 )
223239 expect ( tx . insert ) . toHaveBeenCalledTimes ( 2 )
224240 expect ( tx . delete ) . toHaveBeenCalledTimes ( 1 )
225241 } )
@@ -232,19 +248,19 @@ describe('syncPersonalEnvCredentialsForUser', () => {
232248 delete : vi . fn ( base . delete ) ,
233249 } as unknown as DbOrTx
234250 dbChainMockFns . transaction . mockImplementationOnce ( async ( callback ) => callback ( tx ) )
251+ queueTableRows ( environment , [ { variables : { API_KEY : 'encrypted' } } ] )
235252 queueTableRows ( permissions , [ ] )
236253 queueTableRows ( workspace , [ ] )
237254
238255 await syncPersonalEnvCredentialsForUser ( {
239256 userId : 'user-1' ,
240- envKeys : [ 'API_KEY' ] ,
241257 } )
242258
243259 expect ( mockAcquireUserBillingIdentityLock . mock . invocationCallOrder [ 0 ] ) . toBeLessThan (
244260 ( tx . select as ReturnType < typeof vi . fn > ) . mock . invocationCallOrder [ 0 ]
245261 )
246262 expect ( tx . insert ) . not . toHaveBeenCalled ( )
247- expect ( tx . delete ) . not . toHaveBeenCalled ( )
263+ expect ( tx . delete ) . toHaveBeenCalledTimes ( 1 )
248264 } )
249265
250266 it ( 'syncs every workspace with one credential insert, lookup, membership insert, and cleanup' , async ( ) => {
@@ -255,23 +271,74 @@ describe('syncPersonalEnvCredentialsForUser', () => {
255271 delete : vi . fn ( base . delete ) ,
256272 } as unknown as DbOrTx
257273 dbChainMockFns . transaction . mockImplementationOnce ( async ( callback ) => callback ( tx ) )
274+ queueTableRows ( environment , [ { variables : { API_KEY : 'encrypted' } } ] )
258275 queueTableRows ( permissions , [ { workspaceId : 'ws-2' } , { workspaceId : 'ws-1' } ] )
259276 queueTableRows ( workspace , [ ] )
260277 queueTableRows ( credential , [ { id : 'credential-1' } , { id : 'credential-2' } ] )
261278
262279 await syncPersonalEnvCredentialsForUser ( {
263280 userId : 'user-1' ,
264- envKeys : [ 'API_KEY' ] ,
265281 } )
266282
267- expect ( tx . select ) . toHaveBeenCalledTimes ( 3 )
283+ expect ( tx . select ) . toHaveBeenCalledTimes ( 4 )
268284 expect ( tx . insert ) . toHaveBeenCalledTimes ( 2 )
269285 expect ( tx . delete ) . toHaveBeenCalledTimes ( 1 )
270286 expect ( dbChainMockFns . values ) . toHaveBeenNthCalledWith ( 1 , [
271287 expect . objectContaining ( { workspaceId : 'ws-1' , envKey : 'API_KEY' } ) ,
272288 expect . objectContaining ( { workspaceId : 'ws-2' , envKey : 'API_KEY' } ) ,
273289 ] )
274290 } )
291+
292+ it . each ( [
293+ { label : 'missing' , rows : [ ] } ,
294+ { label : 'empty' , rows : [ { variables : { } } ] } ,
295+ ] ) (
296+ 'cleans archived-workspace mirrors with a $label map and no active workspaces' ,
297+ async ( { rows } ) => {
298+ queueTableRows ( environment , rows )
299+ const deleteWhere = vi . fn ( ) . mockResolvedValue ( [ ] )
300+ dbChainMock . db . delete . mockReturnValue ( { where : deleteWhere } )
301+
302+ await syncPersonalEnvCredentialsForUser ( { userId : 'user-1' } )
303+
304+ expect ( dbChainMock . db . delete ) . toHaveBeenCalledWith ( credential )
305+ expect ( flattenMockConditions ( deleteWhere . mock . calls [ 0 ] [ 0 ] ) ) . toEqual ( [
306+ { type : 'eq' , left : credential . type , right : 'env_personal' } ,
307+ { type : 'eq' , left : credential . envOwnerUserId , right : 'user-1' } ,
308+ ] )
309+ expect ( dbChainMockFns . insert ) . not . toHaveBeenCalled ( )
310+ }
311+ )
312+
313+ it . each ( [
314+ { label : 'no active workspaces' , activeWorkspaces : [ ] } ,
315+ { label : 'an active workspace' , activeWorkspaces : [ { workspaceId : 'active-workspace' } ] } ,
316+ ] ) (
317+ 'prunes deleted keys across archived workspaces with $label, preserving current keys and owners' ,
318+ async ( { activeWorkspaces } ) => {
319+ queueTableRows ( environment , [ { variables : { KEEP : 'encrypted' , NEW : 'encrypted-new' } } ] )
320+ queueTableRows ( permissions , activeWorkspaces )
321+ queueTableRows ( workspace , [ ] )
322+ const deleteWhere = vi . fn ( ) . mockResolvedValue ( [ ] )
323+ dbChainMock . db . delete . mockReturnValue ( { where : deleteWhere } )
324+
325+ await syncPersonalEnvCredentialsForUser ( { userId : 'user-1' } )
326+
327+ expect ( flattenMockConditions ( deleteWhere . mock . calls [ 0 ] [ 0 ] ) ) . toEqual ( [
328+ { type : 'eq' , left : credential . type , right : 'env_personal' } ,
329+ { type : 'eq' , left : credential . envOwnerUserId , right : 'user-1' } ,
330+ { type : 'notInArray' , column : credential . envKey , values : [ 'KEEP' , 'NEW' ] } ,
331+ ] )
332+ if ( activeWorkspaces . length === 0 ) {
333+ expect ( dbChainMockFns . insert ) . not . toHaveBeenCalled ( )
334+ } else {
335+ expect ( dbChainMockFns . values ) . toHaveBeenCalledWith ( [
336+ expect . objectContaining ( { workspaceId : 'active-workspace' , envKey : 'KEEP' } ) ,
337+ expect . objectContaining ( { workspaceId : 'active-workspace' , envKey : 'NEW' } ) ,
338+ ] )
339+ }
340+ }
341+ )
275342} )
276343
277344describe ( 'createWorkspaceEnvCredentials' , ( ) => {
0 commit comments