credentialVaultPanel.test.ts 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990
  1. import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
  2. import { afterEach, beforeEach, expect, it, vi } from 'vitest'
  3. import CredentialVaultPanel from '@/components/app/CredentialVaultPanel.vue'
  4. const api=vi.hoisted(()=>vi.fn())
  5. vi.mock('@/api/client',()=>({apiRequest:api}))
  6. let wrapper:VueWrapper|undefined
  7. const initial={protocol:1,online:true,initialized:true,unlocked:false,execution:'disabled',entries:[],leases:[],tasks:[],requests:[]}
  8. let state:any
  9. function deferred(){let resolve!:(value:any)=>void;const promise=new Promise<any>(r=>{resolve=r});return {resolve,promise}}
  10. beforeEach(()=>{state=structuredClone(initial);api.mockImplementation(async(_path,options)=>options?.method==='POST'?{}:structuredClone(state))})
  11. afterEach(()=>{wrapper?.unmount();wrapper=undefined;vi.useRealTimers();vi.restoreAllMocks();api.mockReset()})
  12. async function setup(){wrapper=mount(CredentialVaultPanel);await flushPromises();return wrapper}
  13. it('shows an offline state without offering secret entry and explicitly keeps real login disabled',async()=>{
  14. state.online=false;const w=await setup()
  15. expect(w.find('input[type=password]').exists()).toBe(false);expect(w.text()).toContain('尚未启用');expect(w.text()).toContain('不会自动安装或启动服务')
  16. })
  17. it('offers creation and credential entry in local storage mode without offering Bot leases',async()=>{
  18. state.initialized=false;state.storageMode='local';const w=await setup()
  19. expect(w.text()).toContain('保存凭据无需配置独立服务');expect(w.text()).toContain('创建密码库')
  20. state.initialized=true;state.unlocked=true
  21. await w.findAll('button').find(b=>b.text()==='刷新状态')!.trigger('click');await flushPromises()
  22. await w.findAll('button').find(b=>b.text()==='添加凭据')!.trigger('click')
  23. const editor=w.findAll('form')[0]!,label=(text:string)=>editor.findAll('label').find(l=>l.text().startsWith(text))!
  24. await label('名称').get('input').setValue('Dummy site');await label('用户名').get('input').setValue('dummy')
  25. await label('准确网站').get('input').setValue('https://example.test');await label('密码').get('input').setValue('DUMMY-new-secret')
  26. await editor.trigger('submit');await flushPromises()
  27. expect(api).toHaveBeenCalledWith('/api/app/vault/entries',expect.objectContaining({method:'POST',body:{name:'Dummy site',username:'dummy',target:{kind:'website',origin:'https://example.test'},secret:'DUMMY-new-secret'}}))
  28. expect(w.text()).not.toContain('授予本任务');expect(w.text()).not.toContain('DUMMY-new-secret');expect(w.find('input[type=password]').exists()).toBe(true)
  29. })
  30. it('reports a wrong vault master password without expiring the application login or echoing errors',async()=>{
  31. const w=await setup();await w.get('input[type=password]').setValue('DUMMY-wrong-master')
  32. api.mockImplementation(async(path)=>{if(path.endsWith('/unlock'))throw Object.assign(new Error('DUMMY-private-error-DUMMY-wrong-master'),{code:'vault_unlock_failed'});return structuredClone(state)})
  33. await w.get('form').trigger('submit');await flushPromises()
  34. expect(api).toHaveBeenCalledWith('/api/app/vault/unlock',expect.objectContaining({notifyUnauthorized:false}))
  35. expect(w.get('[role=alert]').text()).toContain('主密码错误');expect(w.text()).not.toContain('DUMMY-private-error');expect(w.text()).not.toContain('DUMMY-wrong-master')
  36. expect(w.get<HTMLInputElement>('input[type=password]').element.value).toBe('')
  37. })
  38. it('preserves a master password draft while locked status is polled, then clears it after manual unlock',async()=>{
  39. vi.useFakeTimers();const storage=vi.spyOn(Storage.prototype,'setItem'),w=await setup(),input=w.get<HTMLInputElement>('input[type=password]')
  40. await input.setValue('DUMMY-ONLY-master');await vi.advanceTimersByTimeAsync(3000);await flushPromises();expect(input.element.value).toBe('DUMMY-ONLY-master')
  41. await w.get('form').trigger('submit');await flushPromises()
  42. expect(api).toHaveBeenCalledWith('/api/app/vault/unlock',expect.objectContaining({method:'POST',body:{password:'DUMMY-ONLY-master',seconds:300}}))
  43. expect(input.element.value).toBe('');expect(storage).not.toHaveBeenCalled()
  44. })
  45. it('checks confirmation before initialization and leaves no draft in unmounted UI',async()=>{
  46. state.initialized=false;const w=await setup(),inputs=w.findAll('input[type=password]')
  47. await inputs[0]!.setValue('dummy-master-A');await inputs[1]!.setValue('dummy-master-B');await w.get('form').trigger('submit')
  48. expect(w.get('[role=alert]').text()).toContain('不一致');expect(api.mock.calls.filter(([,o])=>o?.method==='POST')).toHaveLength(0)
  49. w.unmount();wrapper=undefined
  50. })
  51. it('editing exposes no saved secret and omits a blank replacement in the request',async()=>{
  52. state.unlocked=true;state.entries=[{id:'dummy-id',name:'dummy',username:'fixture',target:{kind:'website',origin:'https://example.test'},revision:3,updatedAt:1}]
  53. const w=await setup();await w.findAll('button').find(b=>b.text()==='编辑')!.trigger('click')
  54. const password=w.get<HTMLInputElement>('input[type=password]');expect(password.element.value).toBe('')
  55. await w.findAll('form')[0]!.trigger('submit');await flushPromises()
  56. const call=api.mock.calls.find(([p,o])=>p==='/api/app/vault/entries/dummy-id'&&o.method==='PUT')!
  57. expect(call[1].body).not.toHaveProperty('secret');expect(call[1].body.revision).toBe(3)
  58. expect(api.mock.calls.some(([p])=>p.includes('read-secret'))).toBe(false)
  59. })
  60. it('unmount aborts an in-flight unlock and sends an explicit lock without a password',async()=>{
  61. const w=await setup();await w.get('input[type=password]').setValue('dummy-master-only')
  62. const pending=deferred();api.mockImplementation((p)=>p.endsWith('/unlock')?pending.promise:Promise.resolve({}))
  63. await w.get('form').trigger('submit');const call=api.mock.calls.find(([p])=>p.endsWith('/unlock'))!
  64. w.unmount();wrapper=undefined;expect(call[1].signal.aborted).toBe(true)
  65. expect(api).toHaveBeenCalledWith('/api/app/vault/lock',expect.objectContaining({body:{}}));pending.resolve({});await flushPromises()
  66. })
  67. it('hiding the page locks even while unlock is still pending',async()=>{
  68. const w=await setup();await w.get('input[type=password]').setValue('dummy-master-only')
  69. const pending=deferred();api.mockImplementation(p=>p.endsWith('/unlock')?pending.promise:Promise.resolve({}))
  70. await w.get('form').trigger('submit');const call=api.mock.calls.find(([p])=>p.endsWith('/unlock'))!
  71. vi.spyOn(document,'hidden','get').mockReturnValue(true);document.dispatchEvent(new Event('visibilitychange'))
  72. await w.vm.$nextTick()
  73. expect(call[1].signal.aborted).toBe(true);expect(api).toHaveBeenCalledWith('/api/app/vault/lock',expect.objectContaining({body:{}}))
  74. expect(w.get<HTMLInputElement>('input[type=password]').element.value).toBe('');pending.resolve({});await flushPromises()
  75. })
  76. it('configures an encrypted fixed SFTP body in the dedicated UI, clears it after save and never reads it back',async()=>{
  77. state.unlocked=true;state.execution='protected-adapters';const w=await setup()
  78. await w.findAll('button').find(b=>b.text()==='添加凭据')!.trigger('click')
  79. const editor=w.findAll('form')[0]!
  80. await editor.get('select').setValue('ssh');await editor.get('input[type=checkbox]').setValue(true)
  81. await editor.findAll('select')[1]!.setValue('sftp.write')
  82. const label=(text:string)=>editor.findAll('label').find(l=>l.text().startsWith(text))!
  83. await label('名称').get('input').setValue('dummy transfer');await label('用户名').get('input').setValue('dummy')
  84. await label('SSH 主机').get('input').setValue('127.0.0.1');await label('已人工确认').get('input').setValue('SHA256:'+'A'.repeat(43))
  85. await label('私钥').get('textarea').setValue('DUMMY-private-key');await label('批准的远端').get('input').setValue('/approved/new.txt')
  86. await label('固定写入正文').get('textarea').setValue('DUMMY-private-write-body')
  87. await editor.trigger('submit');await flushPromises()
  88. expect(api).toHaveBeenCalledWith('/api/app/vault/entries',expect.objectContaining({method:'POST',body:expect.objectContaining({usage:{kind:'sftp.write',remotePath:'/approved/new.txt',contents:'DUMMY-private-write-body'}})}))
  89. expect(w.find('textarea').exists()).toBe(false);expect(w.text()).not.toContain('DUMMY-private-write-body')
  90. })