| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201 |
- // @vitest-environment node
- import { afterEach, expect, it, vi } from 'vitest'
- import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'
- import { join } from 'node:path'
- import { tmpdir } from 'node:os'
- import { randomUUID } from 'node:crypto'
- import { createCredentialVaultClient, LocalCredentialVaultClient } from '../../src/server/credentialVault/localClient'
- import { sshFixture } from '../fixtures/credentialProtocols'
- const homes: string[] = [], clients: LocalCredentialVaultClient[] = []
- const session = 'a'.repeat(64), password = 'DUMMY-local-master-only', secret = 'DUMMY-local-website-secret'
- function client(home = mkdtempSync(join(tmpdir(), 'yaoyao-local-vault-'))) {
- if (!homes.includes(home)) homes.push(home)
- const value = new LocalCredentialVaultClient(home); clients.push(value)
- return { home, value, call: (command: string, body?: unknown, owner = 'dummy-owner', binding = session) => value.call(owner, binding, command, body) }
- }
- afterEach(async () => { clients.splice(0).forEach(c => c.close()); await Promise.resolve(); homes.splice(0).forEach(h => rmSync(h, { recursive: true, force: true })) })
- it('provides encrypted local CRUD and bounded task execution by default without an external service', async () => {
- const f = client()
- expect(await f.call('status')).toMatchObject({ online: true, initialized: false, unlocked: false, execution: 'local-adapters', reason: 'local_controlled', storageMode: 'local' })
- await f.call('initialize', { password })
- await expect(f.call('add', {})).rejects.toMatchObject({ code: 'vault_locked' })
- await f.call('unlock', { password })
- const entry = await f.call('add', { name: 'Dummy website', username: 'dummy', target: { kind: 'website', origin: 'https://example.test' }, secret })
- expect(entry).not.toHaveProperty('secret')
- const stored = readdirSync(join(f.home, 'credential-vault')).filter(name => name.endsWith('.vault')).map(name => readFileSync(join(f.home, 'credential-vault', name), 'utf8')).join('')
- expect(stored).not.toContain(secret); expect(stored).not.toContain(password); expect(stored).not.toContain('Dummy website')
- expect((await f.call('status')).entries).toHaveLength(1)
- await expect(f.call('read-secret', {id:entry.id})).rejects.toMatchObject({ code: 'vault_command_unknown' })
- await f.call('update', { id: entry.id, entry: { name: 'Updated', username: 'dummy', target: entry.target, revision: entry.revision } })
- expect((await f.call('status')).entries[0].name).toBe('Updated')
- await f.call('remove', { id: entry.id }); expect((await f.call('status')).entries).toEqual([])
- })
- it('binds one-use local execution to the owner and session and requires a configured usage policy',async()=>{
- const f=client();await f.call('initialize',{password});await f.call('unlock',{password})
- const entry=await f.call('add',{name:'Dummy',username:'dummy',target:{kind:'website',origin:'https://example.test'},secret})
- const input={credentialRef:entry.id,agentId:randomUUID(),workId:randomUUID(),runnerId:randomUUID(),runnerInstance:randomUUID(),runnerEpoch:randomUUID(),operation:'website.login',target:entry.target,seconds:60}
- const lease=await f.call('grant',input)
- await expect(f.call('execute',{id:lease.id,input},'another-owner')).rejects.toMatchObject({code:'vault_lease_revoked'})
- await expect(f.call('execute',{id:lease.id,input},'dummy-owner','b'.repeat(64))).rejects.toMatchObject({code:'vault_scope_forbidden'})
- expect(await f.call('execute',{id:lease.id,input})).toEqual({status:'manual_takeover_required',reason:'usage_policy_required',submitted:false})
- await expect(f.call('execute',{id:lease.id,input})).rejects.toMatchObject({code:'vault_lease_revoked'})
- })
- it('saves SSH offline, remembers the first host without authenticating, persists its pin and rejects a changed host before sending a password',async()=>{
- const f=client();await f.call('initialize',{password});await f.call('unlock',{password})
- let ssh=await sshFixture('password')
- try{
- const {hostKey:_initialKey,...target}=ssh.target
- await ssh.close() // The server is actually offline while the password is saved.
- const entry=await f.call('add',{name:'Dummy SSH',username:ssh.username,target,secret:ssh.secret,usage:{kind:'ssh.exec',command:'/usr/bin/uptime'}})
- ssh=await sshFixture('password',target.port);const hostKey=ssh.target.hostKey
- const input={credentialRef:entry.id,agentId:randomUUID(),workId:randomUUID(),runnerId:randomUUID(),runnerInstance:randomUUID(),runnerEpoch:randomUUID(),operation:'ssh.exec',target,seconds:60}
- expect(ssh.state.authMethods).toEqual([])
- await expect(f.call('grant',input)).rejects.toMatchObject({code:'vault_host_key_required'})
- await expect(f.call('prepare-host',{id:entry.id,revision:entry.revision},'dummy-owner','b'.repeat(64))).rejects.toMatchObject({code:'vault_locked'})
- const prepared=await f.call('prepare-host',{id:entry.id,revision:entry.revision})
- expect(prepared.target.hostKey).toBe(hostKey);expect(ssh.state.authMethods).toEqual([])
- await f.call('lock');await f.call('unlock',{password});expect((await f.call('status')).entries[0].target.hostKey).toBe(hostKey)
- const port=target.port;await ssh.close();ssh=await sshFixture('password',port)
- expect(ssh.target.hostKey).not.toBe(hostKey)
- expect((await f.call('prepare-host',{id:entry.id,revision:prepared.revision})).target.hostKey).toBe(hostKey)
- const bound={...input,target:prepared.target},lease=await f.call('grant',bound)
- await expect(f.call('execute',{id:lease.id,input:bound})).rejects.toMatchObject({code:'vault_host_key_rejected'})
- expect(ssh.state.authMethods).toEqual([]);expect(ssh.state.commands).toEqual([])
- expect((await f.call('status')).entries[0].target.hostKey).toBe(hostKey)
- }finally{await ssh.close()}
- },15000)
- it('isolates accounts and login sessions and starts locked after a server restart', async () => {
- const f = client(); await f.call('initialize', { password }); await f.call('unlock', { password })
- await f.call('add', { name: 'Dummy', username: 'dummy', target: { kind: 'website', origin: 'https://example.test' }, secret })
- expect(await f.call('status', undefined, 'another-owner')).toMatchObject({ initialized: false, entries: [] })
- expect(await f.call('status', undefined, 'dummy-owner', 'b'.repeat(64))).toMatchObject({ unlocked: false, entries: [] })
- await expect(f.call('add', {}, 'dummy-owner', 'b'.repeat(64))).rejects.toMatchObject({ code: 'vault_locked' })
- f.value.close(); await Promise.resolve()
- const restarted = client(f.home)
- expect(await restarted.call('status')).toMatchObject({ initialized: true, unlocked: false, entries: [] })
- await restarted.call('unlock', { password }); expect((await restarted.call('status')).entries).toHaveLength(1)
- })
- it('keeps an explicitly configured but unavailable private broker offline', async () => {
- const c = createCredentialVaultClient({ socket: '/missing/socket', tokenFile: '/missing/token', hermesUid: process.getuid!() + 1 }, '/unused')
- expect(c).not.toBeInstanceOf(LocalCredentialVaultClient)
- await expect(c.call('owner', session, 'status')).rejects.toMatchObject({ code: 'vault_offline' })
- c.close()
- })
- it('prevents concurrent brokers from writing the same local vault', async () => {
- const first = client(); await first.call('initialize', { password })
- const second = client(first.home)
- await expect(second.call('status')).rejects.toMatchObject({ code: 'vault_offline' })
- first.value.close(); await Promise.resolve()
- expect(await second.call('status')).toMatchObject({ initialized: true, unlocked: false })
- })
- it('keeps specified Bot approval across management lock and restart, without exposing secrets, and revokes it explicitly',async()=>{
- const ssh=await sshFixture('password'),f=client(),agentId=randomUUID(),owner='dummy-owner'
- const controller=new AbortController()
- try{
- await f.call('initialize',{password});await f.call('unlock',{password})
- const {hostKey:_hostKey,...target}=ssh.target
- const entry=await f.call('add',{name:'Dummy SSH',username:ssh.username,target,secret:ssh.secret,usage:{kind:'ssh.exec',command:'/usr/bin/uptime'}})
- await expect(f.call('bot-grant',{credentialRef:entry.id,agentId},owner,'b'.repeat(64))).rejects.toMatchObject({code:'vault_locked'})
- await f.call('bot-grant',{credentialRef:entry.id,agentId})
- expect(ssh.state.authMethods).toEqual([])
- await f.call('lock')
- await expect(f.call('bot-grant',{credentialRef:entry.id,agentId})).rejects.toMatchObject({code:'vault_locked'})
- const refs=await f.call('bot-refs',{agentId})
- expect(refs).toHaveLength(1);expect(refs[0]).toMatchObject({credentialRef:entry.id,agentId,authorized:true,allowedTarget:target})
- expect((await f.call('status')).unlocked).toBe(false)
- expect(await f.call('bot-refs',{agentId:randomUUID()})).toEqual([])
- expect(await f.call('bot-refs',{agentId},'other-owner')).toEqual([])
- const input={credentialRef:entry.id,agentId,workId:randomUUID(),runnerId:randomUUID(),runnerInstance:randomUUID(),runnerEpoch:randomUUID(),operation:'ssh.exec',target,seconds:60}
- await expect(f.value.executeBot('other-owner',input,controller.signal)).rejects.toMatchObject({code:'vault_bot_forbidden'})
- await expect(f.value.executeBot(owner,{...input,agentId:randomUUID()},controller.signal)).rejects.toMatchObject({code:'vault_bot_forbidden'})
- f.value.close();await Promise.resolve();const restarted=client(f.home)
- expect(await restarted.call('status')).toMatchObject({unlocked:false,botGrants:[{credentialRef:entry.id,agentId}]})
- expect(await restarted.value.executeBot(owner,input,controller.signal)).toEqual({status:'complete',operation:'ssh.exec',exitCode:0})
- expect(ssh.state.commands).toEqual(['/usr/bin/uptime'])
- const directory=join(f.home,'credential-vault','bot-access')
- const disk=readdirSync(directory).map(n=>readFileSync(join(directory,n),'utf8')).join('')
- for(const value of [ssh.secret,ssh.username,password,'Dummy SSH'])expect(disk).not.toContain(value)
- const publicData=JSON.stringify([refs,(await restarted.call('status')).botGrants])
- for(const value of [ssh.secret,ssh.username,password])expect(publicData).not.toContain(value)
- await restarted.call('unlock',{password});await restarted.call('bot-revoke',{credentialRef:entry.id,agentId});await restarted.call('lock')
- expect(await restarted.call('bot-refs',{agentId})).toEqual([])
- await expect(restarted.value.executeBot(owner,input,controller.signal)).rejects.toMatchObject({code:'vault_bot_forbidden'})
- }finally{await ssh.close()}
- },15000)
- it('management expiry and editing preserve Bot approval while deletion aborts in-flight use',async()=>{
- const ssh=await sshFixture('password'),f=client(),agentId=randomUUID(),owner='dummy-owner'
- try{
- await f.call('initialize',{password});await f.call('unlock',{password})
- const entry=await f.call('add',{name:'Dummy SSH',username:ssh.username,target:ssh.target,secret:ssh.secret,usage:{kind:'ssh.exec',command:'/usr/bin/uptime'}})
- const grant={credentialRef:entry.id,agentId};await f.call('bot-grant',grant)
- const broker=await (f.value as any).broker
- const future=vi.spyOn(broker.store,'now').mockReturnValue(Date.now()+301000)
- expect(await f.call('status')).toMatchObject({unlocked:false});expect(await f.call('bot-refs',{agentId})).toHaveLength(1);future.mockRestore()
- await f.call('unlock',{password})
- await f.call('update',{id:entry.id,entry:{name:'Updated',username:ssh.username,target:entry.target,revision:entry.revision}})
- expect(await f.call('bot-refs',{agentId})).toMatchObject([{name:'Updated',authorized:true}])
- await f.call('lock')
- ssh.state.delayExec=true
- const input={...grant,workId:randomUUID(),runnerId:randomUUID(),runnerInstance:randomUUID(),runnerEpoch:randomUUID(),operation:'ssh.exec',target:ssh.target,seconds:60}
- const pending=f.value.executeBot(owner,input,new AbortController().signal),assertion=expect(pending).rejects.toMatchObject({code:'vault_operation_uncertain'})
- await ssh.executed;await f.call('unlock',{password});await f.call('remove',{id:entry.id});await assertion
- expect(await f.call('bot-refs',{agentId})).toEqual([])
- }finally{vi.restoreAllMocks();await ssh.close()}
- },15000)
- it('keeps two Bots authorized when the SSH port changes offline, uses the new port after restart, and retains a learned pin on later edits',async()=>{
- let ssh=await sshFixture('password');const second=await sshFixture('password'),f=client(),owner='dummy-owner',bots=[randomUUID(),randomUUID()]
- try{
- await f.call('initialize',{password});await f.call('unlock',{password})
- let entry=await f.call('add',{name:'Dummy SSH',username:ssh.username,target:ssh.target,secret:ssh.secret,usage:{kind:'ssh.exec',command:'/usr/bin/uptime'}})
- for(const agentId of bots)await f.call('bot-grant',{credentialRef:entry.id,agentId})
- await expect(f.call('update',{id:entry.id,entry:{name:'Conflict',username:ssh.username,target:entry.target,revision:99}})).rejects.toMatchObject({code:'vault_revision_conflict'})
- expect(await f.call('bot-refs')).toHaveLength(2)
- const newPort=second.target.port;await second.close()
- // An editor sends the existing pin; changing the endpoint discards that old pin.
- entry=await f.call('update',{id:entry.id,entry:{name:'New port',username:ssh.username,target:{...entry.target,port:newPort},revision:entry.revision}})
- expect(entry.target.hostKey).toBeUndefined()
- await f.call('lock');f.value.close();await Promise.resolve();const restarted=client(f.home)
- const input=(agentId:string,target:any)=>({credentialRef:entry.id,agentId,workId:randomUUID(),runnerId:randomUUID(),runnerInstance:randomUUID(),runnerEpoch:randomUUID(),operation:'ssh.exec',target,seconds:60})
- expect((await restarted.call('status')).unlocked).toBe(false);expect((await restarted.call('status')).botGrants).toHaveLength(2)
- // Server offline means connection failure, never an empty authorization list.
- await expect(restarted.value.executeBot(owner,input(bots[0]!,entry.target),new AbortController().signal)).rejects.toMatchObject({code:'vault_host_probe_failed'})
- expect(await restarted.call('bot-refs')).toHaveLength(2)
- await ssh.close();ssh=await sshFixture('password',newPort)
- for(const agentId of bots)expect(await restarted.value.executeBot(owner,input(agentId,entry.target),new AbortController().signal)).toMatchObject({status:'complete',exitCode:0})
- expect(ssh.state.commands).toEqual(['/usr/bin/uptime','/usr/bin/uptime'])
- await restarted.call('unlock',{password})
- entry=await restarted.call('update',{id:entry.id,entry:{name:'Rename only',username:ssh.username,target:entry.target,revision:entry.revision}})
- for(const agentId of bots)expect((await restarted.call('bot-refs',{agentId}))[0].allowedTarget.hostKey).toBe(ssh.target.hostKey)
- await restarted.call('lock')
- const port=ssh.target.port;await ssh.close();ssh=await sshFixture('password',port)
- await expect(restarted.value.executeBot(owner,input(bots[0]!,entry.target),new AbortController().signal)).rejects.toMatchObject({code:'vault_host_key_rejected'})
- expect(ssh.state.authMethods).toEqual([]);expect(await restarted.call('bot-refs')).toHaveLength(2)
- }finally{await ssh.close()}
- },15000)
- it('updates cancel in-flight old operations without dropping approval for the next operation',async()=>{
- const ssh=await sshFixture('password'),f=client(),agentId=randomUUID()
- try{
- await f.call('initialize',{password});await f.call('unlock',{password})
- const entry=await f.call('add',{name:'Dummy SSH',username:ssh.username,target:ssh.target,secret:ssh.secret,usage:{kind:'ssh.exec',command:'/usr/bin/uptime'}})
- await f.call('bot-grant',{credentialRef:entry.id,agentId});await f.call('lock');ssh.state.delayExec=true
- const input={credentialRef:entry.id,agentId,workId:randomUUID(),runnerId:randomUUID(),runnerInstance:randomUUID(),runnerEpoch:randomUUID(),operation:'ssh.exec',target:ssh.target,seconds:60}
- const pending=f.value.executeBot('dummy-owner',input,new AbortController().signal),assertion=expect(pending).rejects.toMatchObject({code:'vault_operation_uncertain'})
- await ssh.executed;await f.call('unlock',{password})
- await f.call('update',{id:entry.id,entry:{name:'New operation',username:ssh.username,target:ssh.target,revision:entry.revision,usage:{kind:'ssh.exec',command:'/usr/bin/true'}}})
- await assertion;await f.call('lock');ssh.state.delayExec=false
- expect(await f.value.executeBot('dummy-owner',input,new AbortController().signal)).toMatchObject({status:'complete',exitCode:0})
- expect(ssh.state.commands).toEqual(['/usr/bin/uptime','/usr/bin/true'])
- expect(await f.call('bot-refs',{agentId})).toMatchObject([{authorized:true,name:'New operation'}])
- }finally{await ssh.close()}
- },15000)
|