credentialVaultCoordinator.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118
  1. // @vitest-environment node
  2. import { afterEach, expect, it, vi } from 'vitest'
  3. import type Koa from 'koa'
  4. import { mkdtempSync, rmSync, writeFileSync, chmodSync } from 'node:fs'
  5. import { tmpdir } from 'node:os'
  6. import { join } from 'node:path'
  7. import { randomUUID } from 'node:crypto'
  8. import { once } from 'node:events'
  9. import { CredentialVaultStore } from '../../src/server/credentialVault/store'
  10. import { CredentialVaultBroker } from '../../src/server/credentialVault/broker'
  11. import { CredentialVaultClient, serveCredentialVault } from '../../src/server/credentialVault/transport'
  12. import { CredentialVaultCoordinator } from '../../src/server/credentialVault/coordinator'
  13. import { LocalAuthStore } from '../../src/server/localAuth'
  14. import { HttpError } from '../../src/server/errors'
  15. import type { WorkspaceStore } from '../../src/server/workspaceStore'
  16. import type { RunnerHub } from '../../src/server/runnerHub'
  17. import type { Server } from 'node:http'
  18. const cleanups:Array<()=>Promise<void>>=[]
  19. const password='DUMMY-vault-master-only',secret='DUMMY-website-secret-only'
  20. function context(cookie='',path='',method='GET',body:unknown={}):Koa.Context{
  21. const headers:Record<string,any>={}
  22. return {state:{},get:(key:string)=>key.toLowerCase()==='cookie'?cookie:'',set:(key:string,value:any)=>{headers[key.toLowerCase()]=value},response:{headers},path,method,request:{body},params:{},secure:false,req:{socket:{remoteAddress:'127.0.0.1'}}} as unknown as Koa.Context
  23. }
  24. function cookie(ctx:Koa.Context){const value=ctx.response.headers['set-cookie'];return String(Array.isArray(value)?value.at(-1):value).split(';')[0]!}
  25. async function fixture(ipc=false){
  26. const home=mkdtempSync(join(tmpdir(),'yaoyao-vault-coordinator-')),auth=new LocalAuthStore(home),login=context(),user=auth.setupAdmin(login,'dummy-user','dummy-login-password'),cookies=cookie(login)
  27. const vault=await CredentialVaultStore.create(join(home,'vault')),broker=new CredentialVaultBroker(vault)
  28. let client:CredentialVaultClient,server:Server|undefined
  29. if(ipc){
  30. const socket=join(home,'dummy.sock'),tokenFile=join(home,'dummy-control'),token='DUMMY-CONTROL-TOKEN-NOT-PRODUCTION-00000'
  31. writeFileSync(tokenFile,token,{mode:0o600});server=serveCredentialVault(broker,token);server.listen(socket);await once(server,'listening');chmodSync(socket,0o600)
  32. // In-process fixtures do NOT prove OS isolation. Production executors remain disabled.
  33. client=new CredentialVaultClient({socket,tokenFile,hermesUid:process.getuid!()+1})
  34. }else{
  35. const epoch=randomUUID();broker.hello(epoch)
  36. client=new CredentialVaultClient()
  37. vi.spyOn(client,'call').mockImplementation(async(o,s,c,v)=>broker.dispatch(epoch,o,s,c,v))
  38. }
  39. const agent={id:randomUUID(),name:'dummy-Bot',nodeId:'local',profile:'default',archived:false},run={id:randomUUID(),stopRequested:false},work={id:randomUUID(),agentId:agent.id,runId:run.id,status:'running',cancelRequested:false}
  40. const objects:any={agent,run,turn:work},store={require:(o:string,kind:string,id:string)=>{if(o!==user.id||objects[kind]?.id!==id)throw new HttpError(404,'fixture missing','not_found');return objects[kind]},list:(o:string,kind:string)=>o===user.id&&objects[kind]?[objects[kind]]:[]} as unknown as WorkspaceStore
  41. let binding={runnerId:randomUUID(),runnerInstance:randomUUID(),runnerEpoch:'dummy-epoch'}
  42. const hub={credentialBinding:()=>binding} as unknown as RunnerHub,coordinator=new CredentialVaultCoordinator(client,store,auth,hub),router=coordinator.router()
  43. const request=async(path:string,method='GET',body:unknown={},c=cookies)=>{const ctx=context(c,'/api/app/vault'+path,method,body);await router.routes()(ctx,async()=>{});return ctx.body as any}
  44. cleanups.push(async()=>{coordinator.close();if(server){await new Promise<void>(resolve=>server!.close(()=>resolve()));server.closeAllConnections()}else broker.close();rmSync(home,{recursive:true,force:true})})
  45. await request('/initialize','POST',{password})
  46. const unlock=()=>request('/unlock','POST',{password}),status=()=>request('')
  47. await unlock();const entry=await request('/entries','POST',{name:'dummy-site',username:'dummy-user',target:{kind:'website',origin:'https://example.test'},secret})
  48. const grant=()=>request('/leases','POST',{credentialRef:entry.id,agentId:agent.id,workId:work.id,operation:'website.login',seconds:60})
  49. const turn=(signal=new AbortController().signal,waiting=vi.fn())=>coordinator.openTurn(user.id,agent.id,work.id,signal,()=>{},waiting)
  50. return {home,auth,user,cookies,broker,client,coordinator,request,unlock,status,entry,agent,run,work,grant,turn,changeBinding:()=>{binding={...binding,runnerInstance:randomUUID()}},hub}
  51. }
  52. afterEach(async()=>{for(const cleanup of cleanups.splice(0))await cleanup();vi.restoreAllMocks()})
  53. it('uses private IPC for encrypted CRUD and short grants but returns manual takeover instead of a login',async()=>{
  54. const f=await fixture(true),lease=await f.grant(),t=f.turn()
  55. expect((await f.status()).leases).toHaveLength(1)
  56. expect(await t.call('credential_refs',{})).toEqual({references:[{credentialRef:f.entry.id,operation:'website.login',allowedTarget:f.entry.target}]})
  57. expect(await t.call('credential_request',{credentialRef:f.entry.id,operation:'website.login'})).toMatchObject({status:'manual_takeover_required',reason:'vault_executor_not_enabled'})
  58. const status=await f.status();expect(JSON.stringify(status)).not.toContain(secret);expect(status.execution).toBe('disabled');expect(lease.expiresAt-Date.now()).toBeLessThanOrEqual(60000)
  59. t.close();await vi.waitFor(async()=>expect((await f.status()).leases).toHaveLength(0))
  60. })
  61. it.each(['lock','logout','connection'] as const)('rejects a late unlock response following %s and leaves the vault locked',async cause=>{
  62. const f=await fixture();await f.request('/lock','POST',{})
  63. const original=f.client.call.bind(f.client),reached=Promise.withResolvers<void>(),release=Promise.withResolvers<void>()
  64. vi.spyOn(f.client,'call').mockImplementation(async(o,s,c,v)=>{const result=await original(o,s,c,v);if(c==='unlock'){reached.resolve();await release.promise}return result})
  65. const pending=f.unlock(),assertion=expect(pending).rejects.toMatchObject({code:'vault_session_revoked'});await reached.promise
  66. if(cause==='lock')await f.request('/lock','POST',{})
  67. if(cause==='logout'){await f.coordinator.logout(f.user.id,f.cookies);f.auth.logout(context(f.cookies))}
  68. if(cause==='connection')f.client.onDisconnect()
  69. release.resolve();await assertion
  70. expect(f.broker.status(f.user.id,f.auth.sessionBinding(f.cookies)!).unlocked).toBe(false)
  71. })
  72. it.each(['cancel','reconnect','permission'] as const)('revokes a grant created during %s before it can reach the Bot',async cause=>{
  73. const f=await fixture(),original=f.client.call.bind(f.client),reached=Promise.withResolvers<void>(),release=Promise.withResolvers<void>()
  74. vi.spyOn(f.client,'call').mockImplementation(async(o,s,c,v)=>{const result=await original(o,s,c,v);if(c==='grant'){reached.resolve();await release.promise}return result})
  75. const pending=f.grant(),assertion=expect(pending).rejects.toMatchObject({code:cause==='permission'?'vault_session_revoked':'vault_task_revoked'});await reached.promise
  76. if(cause==='cancel')f.work.cancelRequested=true
  77. if(cause==='reconnect')f.changeBinding()
  78. if(cause==='permission')vi.spyOn(f.auth,'pushAuthorizationVersion').mockReturnValue(1000)
  79. release.resolve();await assertion
  80. expect(f.broker.status(f.user.id,f.auth.sessionBinding(f.cookies)!).leases).toHaveLength(0)
  81. })
  82. it('a Bot gets no stale refs after revoke or Runner reconnect and cannot request another Bot task',async()=>{
  83. const f=await fixture(),lease=await f.grant(),t=f.turn()
  84. await f.request('/leases/'+lease.id,'DELETE');expect(await t.call('credential_refs',{})).toEqual({references:[]})
  85. await f.grant();f.changeBinding();expect(await t.call('credential_refs',{})).toEqual({references:[]})
  86. f.work.agentId=randomUUID();await expect(t.call('credential_refs',{})).rejects.toMatchObject({code:'vault_task_revoked'});t.close()
  87. })
  88. it('cancellation releases locked/offline waiting and prevents fallback or pending requests lingering',async()=>{
  89. const f=await fixture(),controller=new AbortController(),waiting=vi.fn();await f.request('/lock','POST',{})
  90. vi.spyOn(f.hub,'credentialBinding').mockImplementation(()=>{throw new HttpError(503,'offline','vault_runner_offline')})
  91. const t=f.turn(controller.signal,waiting),pending=t.call('credential_request',{credentialRef:f.entry.id,operation:'website.login'}),assertion=expect(pending).rejects.toThrow()
  92. expect(waiting).toHaveBeenCalledWith(true);expect((await f.status()).requests).toHaveLength(1)
  93. controller.abort();await assertion;expect(waiting).toHaveBeenLastCalledWith(false);expect((await f.status()).requests).toHaveLength(0);t.close()
  94. })
  95. it('refuses old Runner capability and insecure remote UI immediately',async()=>{
  96. const f=await fixture();vi.spyOn(f.hub,'credentialBinding').mockImplementation(()=>{throw new HttpError(409,'upgrade','vault_runner_upgrade_required')})
  97. const t=f.turn();await expect(t.call('credential_request',{credentialRef:f.entry.id,operation:'website.login'})).rejects.toMatchObject({code:'vault_runner_upgrade_required'});t.close()
  98. const ctx=context(f.cookies,'/api/app/vault/unlock','POST',{password});Object.assign(ctx.req.socket,{remoteAddress:'192.0.2.10'})
  99. await expect(f.coordinator.router().routes()(ctx,async()=>{})).rejects.toMatchObject({code:'vault_https_required'})
  100. })
  101. it('default configuration remains offline and rejects same-UID control without reading a credential file',async()=>{
  102. await expect(new CredentialVaultClient().call('owner','a'.repeat(64),'status')).rejects.toMatchObject({code:'vault_offline'})
  103. await expect(new CredentialVaultClient({socket:'/not-a-real-socket',tokenFile:'/never-read',hermesUid:process.getuid!()}).call('owner','a'.repeat(64),'status')).rejects.toMatchObject({code:'vault_offline'})
  104. })
  105. it('binds the authenticated session token independent of unrelated cookie changes and ordering',async()=>{
  106. const f=await fixture(),status=await f.request('','GET',{},'dummy_csrf=changed; '+f.cookies+'; dummy_extra=1')
  107. expect(status.unlocked).toBe(true)
  108. await f.coordinator.logout(f.user.id,'dummy_extra=2; '+f.cookies)
  109. expect((await f.status()).unlocked).toBe(false)
  110. })
  111. it('parallel requests retain separate approvals and close cancels both waiting calls exactly once',async()=>{
  112. const f=await fixture(),waiting=vi.fn();await f.request('/lock','POST',{})
  113. const t=f.turn(undefined,waiting),one=t.call('credential_request',{credentialRef:f.entry.id,operation:'website.login'}),two=t.call('credential_request',{credentialRef:randomUUID(),operation:'website.login'})
  114. const assertions=[expect(one).rejects.toThrow(),expect(two).rejects.toThrow()]
  115. expect((await f.status()).requests).toHaveLength(2);expect(waiting).toHaveBeenCalledTimes(1)
  116. t.close();await Promise.all(assertions);expect(waiting).toHaveBeenLastCalledWith(false);expect(waiting.mock.calls.filter(([v])=>!v)).toHaveLength(1)
  117. expect((await f.status()).requests).toHaveLength(0)
  118. })