credentialVaultLocalClient.test.ts 5.3 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071
  1. // @vitest-environment node
  2. import { afterEach, expect, it } from 'vitest'
  3. import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'
  4. import { join } from 'node:path'
  5. import { tmpdir } from 'node:os'
  6. import { randomUUID } from 'node:crypto'
  7. import { createCredentialVaultClient, LocalCredentialVaultClient } from '../../src/server/credentialVault/localClient'
  8. const homes: string[] = [], clients: LocalCredentialVaultClient[] = []
  9. const session = 'a'.repeat(64), password = 'DUMMY-local-master-only', secret = 'DUMMY-local-website-secret'
  10. function client(home = mkdtempSync(join(tmpdir(), 'yaoyao-local-vault-'))) {
  11. if (!homes.includes(home)) homes.push(home)
  12. const value = new LocalCredentialVaultClient(home); clients.push(value)
  13. return { home, value, call: (command: string, body?: unknown, owner = 'dummy-owner', binding = session) => value.call(owner, binding, command, body) }
  14. }
  15. afterEach(async () => { clients.splice(0).forEach(c => c.close()); await Promise.resolve(); homes.splice(0).forEach(h => rmSync(h, { recursive: true, force: true })) })
  16. it('provides encrypted local CRUD and bounded task execution by default without an external service', async () => {
  17. const f = client()
  18. expect(await f.call('status')).toMatchObject({ online: true, initialized: false, unlocked: false, execution: 'local-adapters', reason: 'local_controlled', storageMode: 'local' })
  19. await f.call('initialize', { password })
  20. await expect(f.call('add', {})).rejects.toMatchObject({ code: 'vault_locked' })
  21. await f.call('unlock', { password })
  22. const entry = await f.call('add', { name: 'Dummy website', username: 'dummy', target: { kind: 'website', origin: 'https://example.test' }, secret })
  23. expect(entry).not.toHaveProperty('secret')
  24. const stored = readdirSync(join(f.home, 'credential-vault')).filter(name => name.endsWith('.vault')).map(name => readFileSync(join(f.home, 'credential-vault', name), 'utf8')).join('')
  25. expect(stored).not.toContain(secret); expect(stored).not.toContain(password); expect(stored).not.toContain('Dummy website')
  26. expect((await f.call('status')).entries).toHaveLength(1)
  27. await expect(f.call('read-secret', {id:entry.id})).rejects.toMatchObject({ code: 'vault_command_unknown' })
  28. await f.call('update', { id: entry.id, entry: { name: 'Updated', username: 'dummy', target: entry.target, revision: entry.revision } })
  29. expect((await f.call('status')).entries[0].name).toBe('Updated')
  30. await f.call('remove', { id: entry.id }); expect((await f.call('status')).entries).toEqual([])
  31. })
  32. it('binds one-use local execution to the owner and session and requires a configured usage policy',async()=>{
  33. const f=client();await f.call('initialize',{password});await f.call('unlock',{password})
  34. const entry=await f.call('add',{name:'Dummy',username:'dummy',target:{kind:'website',origin:'https://example.test'},secret})
  35. const input={credentialRef:entry.id,agentId:randomUUID(),workId:randomUUID(),runnerId:randomUUID(),runnerInstance:randomUUID(),runnerEpoch:randomUUID(),operation:'website.login',target:entry.target,seconds:60}
  36. const lease=await f.call('grant',input)
  37. await expect(f.call('execute',{id:lease.id,input},'another-owner')).rejects.toMatchObject({code:'vault_lease_revoked'})
  38. await expect(f.call('execute',{id:lease.id,input},'dummy-owner','b'.repeat(64))).rejects.toMatchObject({code:'vault_scope_forbidden'})
  39. expect(await f.call('execute',{id:lease.id,input})).toEqual({status:'manual_takeover_required',reason:'usage_policy_required',submitted:false})
  40. await expect(f.call('execute',{id:lease.id,input})).rejects.toMatchObject({code:'vault_lease_revoked'})
  41. })
  42. it('isolates accounts and login sessions and starts locked after a server restart', async () => {
  43. const f = client(); await f.call('initialize', { password }); await f.call('unlock', { password })
  44. await f.call('add', { name: 'Dummy', username: 'dummy', target: { kind: 'website', origin: 'https://example.test' }, secret })
  45. expect(await f.call('status', undefined, 'another-owner')).toMatchObject({ initialized: false, entries: [] })
  46. expect(await f.call('status', undefined, 'dummy-owner', 'b'.repeat(64))).toMatchObject({ unlocked: false, entries: [] })
  47. await expect(f.call('add', {}, 'dummy-owner', 'b'.repeat(64))).rejects.toMatchObject({ code: 'vault_locked' })
  48. f.value.close(); await Promise.resolve()
  49. const restarted = client(f.home)
  50. expect(await restarted.call('status')).toMatchObject({ initialized: true, unlocked: false, entries: [] })
  51. await restarted.call('unlock', { password }); expect((await restarted.call('status')).entries).toHaveLength(1)
  52. })
  53. it('keeps an explicitly configured but unavailable private broker offline', async () => {
  54. const c = createCredentialVaultClient({ socket: '/missing/socket', tokenFile: '/missing/token', hermesUid: process.getuid!() + 1 }, '/unused')
  55. expect(c).not.toBeInstanceOf(LocalCredentialVaultClient)
  56. await expect(c.call('owner', session, 'status')).rejects.toMatchObject({ code: 'vault_offline' })
  57. c.close()
  58. })
  59. it('prevents concurrent brokers from writing the same local vault', async () => {
  60. const first = client(); await first.call('initialize', { password })
  61. const second = client(first.home)
  62. await expect(second.call('status')).rejects.toMatchObject({ code: 'vault_offline' })
  63. first.value.close(); await Promise.resolve()
  64. expect(await second.call('status')).toMatchObject({ initialized: true, unlocked: false })
  65. })