credentialVaultBroker.test.ts 6.9 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879
  1. // @vitest-environment node
  2. import { afterEach, expect, it, vi } from 'vitest'
  3. import { mkdtempSync, rmSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs'
  4. import { tmpdir } from 'node:os'
  5. import { join } from 'node:path'
  6. import { randomUUID } from 'node:crypto'
  7. import { CredentialVaultStore } from '../../src/server/credentialVault/store'
  8. import { CredentialVaultBroker, type CredentialExecutorFixture } from '../../src/server/credentialVault/broker'
  9. import type { LeaseInput } from '../../src/server/credentialVault/schema'
  10. const homes:string[]=[], brokers:CredentialVaultBroker[]=[]
  11. const owner='dummy-owner', session='a'.repeat(64), otherSession='b'.repeat(64), password='dummy-master-password', secret='DUMMY-CREDENTIAL-NEVER-REAL'
  12. async function fixture(executor?:CredentialExecutorFixture){
  13. const home=mkdtempSync(join(tmpdir(),'yaoyao-vault-broker-'));homes.push(home)
  14. let now=Date.now();const store=await CredentialVaultStore.create(home,()=>now),broker=new CredentialVaultBroker(store,()=>now,executor);brokers.push(broker)
  15. let epoch=randomUUID();broker.hello(epoch)
  16. const dispatch=(command:string,value?:unknown,s=session,o=owner)=>broker.dispatch(epoch,o,s,command,value) as any
  17. dispatch('initialize',{password});dispatch('unlock',{password})
  18. const entry=dispatch('add',{name:'dummy-site',username:'dummy-username',target:{kind:'website',origin:'https://example.test'},secret})
  19. const input:LeaseInput={credentialRef:entry.id,agentId:randomUUID(),workId:randomUUID(),runnerId:randomUUID(),runnerInstance:randomUUID(),runnerEpoch:'dummy-runner-epoch',operation:'website.login',target:entry.target,seconds:60}
  20. return {home,store,broker,dispatch,entry,input,grant:()=>dispatch('grant',input),execute:(id:string,b=input,o=owner,s=session)=>broker.execute(epoch,o,s,id,b),advance:(ms:number)=>{now+=ms},reconnect:()=>{epoch=randomUUID();broker.hello(epoch)}}
  21. }
  22. afterEach(()=>{brokers.splice(0).forEach(b=>b.close());homes.splice(0).forEach(h=>rmSync(h,{recursive:true,force:true}))})
  23. it('production has no execution adapter or read-secret RPC, even after user unlock and grant',async()=>{
  24. const f=await fixture(),lease=f.grant()
  25. await expect(f.execute(lease.id)).rejects.toMatchObject({code:'vault_executor_not_enabled'})
  26. expect(()=>f.dispatch('read-secret',{id:f.entry.id})).toThrowError(expect.objectContaining({code:'vault_command_unknown'}))
  27. const state=f.dispatch('status');expect(JSON.stringify(state)).not.toContain(secret)
  28. expect(state.execution).toBe('disabled');expect(f.dispatch('status',{},otherSession).entries).toEqual([])
  29. })
  30. it('binds a lease to owner, Bot, task, exact target, operation and Runner incarnation',async()=>{
  31. const execute=vi.fn(async()=>{}),f=await fixture({execute}),lease=f.grant()
  32. for(const patch of [{agentId:randomUUID()},{workId:randomUUID()},{runnerId:randomUUID()},{runnerInstance:randomUUID()},{runnerEpoch:'new-epoch'},{operation:'ssh.exec' as const},{target:{kind:'website' as const,origin:'https://other.test'}}])
  33. await expect(f.execute(lease.id,{...f.input,...patch})).rejects.toMatchObject({code:'vault_scope_forbidden'})
  34. await expect(f.execute(lease.id,f.input,'other-owner')).rejects.toMatchObject({code:'vault_lease_revoked'})
  35. await expect(f.execute(lease.id,f.input,owner,otherSession)).rejects.toMatchObject({code:'vault_scope_forbidden'})
  36. expect(()=>f.dispatch('grant',{...f.input,target:{kind:'website',origin:'http://example.test'}})).toThrowError(expect.objectContaining({code:'vault_invalid_request'}))
  37. expect(()=>f.dispatch('grant',{...f.input,target:{kind:'website',origin:'https://other.test'}})).toThrowError(expect.objectContaining({code:'vault_target_forbidden'}))
  38. expect(execute).not.toHaveBeenCalled()
  39. expect(await f.execute(lease.id)).toEqual({status:'complete',operation:'website.login'})
  40. await expect(f.execute(lease.id)).rejects.toMatchObject({code:'vault_lease_revoked'})
  41. expect(execute).toHaveBeenCalledOnce()
  42. })
  43. it('binds SSH grants to a pinned host key and port and rejects plaintext FTP',async()=>{
  44. const f=await fixture({execute:async()=>{}}),target={kind:'ssh',host:'ssh.example.test',port:22,hostKey:'SHA256:'+'A'.repeat(43)}
  45. const entry=f.dispatch('add',{name:'dummy-ssh',username:'dummy',target,secret:'DUMMY-PRIVATE-KEY'})
  46. const input={...f.input,credentialRef:entry.id,target,operation:'sftp.read',seconds:60} as LeaseInput
  47. const l=f.dispatch('grant',input)
  48. await expect(f.execute(l.id,{...input,target:{...input.target,port:2222} as any})).rejects.toMatchObject({code:'vault_scope_forbidden'})
  49. expect(()=>f.dispatch('grant',{...input,operation:'ftp.login'})).toThrowError(expect.objectContaining({code:'vault_invalid_request'}))
  50. expect(()=>f.dispatch('grant',{...input,target:{...target,hostKey:'SHA256:'+'B'.repeat(43)}})).toThrowError(expect.objectContaining({code:'vault_target_forbidden'}))
  51. })
  52. it.each(['lock','logout','revoke','cancel','reconnect','update','remove','expiry'] as const)('aborts an in-flight dummy executor on %s, hides errors and prevents retry',async cause=>{
  53. let release!:()=>void,signal:AbortSignal|undefined
  54. const started=Promise.withResolvers<void>(),f=await fixture({execute:async(entry,_input,s)=>{expect(entry.secret).toBe(secret);signal=s;started.resolve();await new Promise<void>(r=>{release=r})}}),lease=f.grant()
  55. const run=f.execute(lease.id),result=expect(run).rejects.toMatchObject({code:'vault_operation_uncertain'})
  56. await started.promise
  57. await expect(f.execute(lease.id)).rejects.toMatchObject({code:'vault_operation_uncertain'})
  58. if(cause==='lock'||cause==='logout')f.dispatch(cause)
  59. if(cause==='revoke')f.dispatch('revoke',{id:lease.id})
  60. if(cause==='cancel')f.dispatch('revoke-work',{workId:f.input.workId})
  61. if(cause==='reconnect')f.reconnect()
  62. if(cause==='update')f.dispatch('update',{id:f.entry.id,entry:{name:'new-name',username:'dummy',target:f.entry.target,revision:1}})
  63. if(cause==='remove')f.dispatch('remove',{id:f.entry.id})
  64. if(cause==='expiry'){f.advance(300000);f.dispatch('status')}
  65. expect(signal?.aborted).toBe(true);release();await result
  66. await expect(f.execute(lease.id)).rejects.toMatchObject({code:'vault_lease_revoked'})
  67. const audit=readFileSync(join(f.home,'audit.jsonl'),'utf8')
  68. for(const privateValue of [secret,password,'dummy-username','https://example.test'])expect(audit).not.toContain(privateValue)
  69. expect(audit).toContain('not-confirmed')
  70. })
  71. it('logout from another login cannot lock the session that unlocked the vault',async()=>{
  72. const f=await fixture();f.grant();f.dispatch('logout',{},otherSession)
  73. expect(f.dispatch('status').unlocked).toBe(true);f.dispatch('logout');expect(f.dispatch('status').unlocked).toBe(false)
  74. })
  75. it('refuses symlink audit sinks and leaves their destination unchanged',async()=>{
  76. const f=await fixture(),path=join(f.home,'audit.jsonl'),destination=join(f.home,'dummy-destination')
  77. writeFileSync(destination,'unchanged');rmSync(path);symlinkSync(destination,path)
  78. expect(()=>f.dispatch('backup')).toThrow();expect(readFileSync(destination,'utf8')).toBe('unchanged')
  79. })