credential-vault.spec.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130
  1. import { test, expect } from '@playwright/test'
  2. import { mkdirSync } from 'node:fs'
  3. test('a default installation creates and saves encrypted passwords through the settings UI', async ({ page }) => {
  4. const evidence='test-results/credential-vault-default';mkdirSync(evidence,{recursive:true})
  5. const master='DUMMY-browser-vault-master',secret='DUMMY-browser-entry-secret'
  6. await page.goto('/conversations')
  7. await page.getByRole('textbox',{name:'账号',exact:true}).fill('fixture')
  8. await page.getByRole('textbox',{name:'密码',exact:true}).fill('fixture-pass')
  9. await page.getByRole('button',{name:'登录',exact:true}).click()
  10. await page.locator('.desktop-sidebar .sidebar-account-switcher__main').click()
  11. await page.getByRole('menuitem',{name:'我的设置',exact:true}).click()
  12. await page.getByRole('button',{name:'密码管理',exact:true}).click()
  13. const panel=page.locator('.credential-vault')
  14. await expect(panel.getByText(/授权一次即可使用/)).toBeVisible()
  15. await panel.getByLabel('主密码',{exact:true}).fill(master)
  16. await panel.getByLabel('确认主密码',{exact:true}).fill(master)
  17. await panel.getByRole('button',{name:'创建密码库',exact:true}).click()
  18. await expect(panel.getByRole('button',{name:'手动解锁 5 分钟',exact:true})).toBeVisible()
  19. await panel.getByLabel('主密码',{exact:true}).fill(master)
  20. await panel.getByRole('button',{name:'手动解锁 5 分钟',exact:true}).click()
  21. await panel.getByRole('button',{name:'添加凭据',exact:true}).click()
  22. await panel.getByLabel('名称',{exact:true}).fill('浏览器测试网站')
  23. await panel.getByLabel('用户名',{exact:true}).fill('dummy-user')
  24. await panel.getByLabel('准确网站 origin',{exact:true}).fill('https://example.test')
  25. await panel.getByLabel('密码',{exact:true}).fill(secret)
  26. await panel.getByRole('button',{name:'加密保存',exact:true}).click()
  27. await expect(panel.locator('li strong').filter({hasText:'浏览器测试网站'})).toBeVisible()
  28. await expect(panel.getByRole('status').filter({hasText:'凭据已加密保存'})).toBeVisible()
  29. await expect(panel.getByRole('button',{name:'授权此 Bot 使用'})).toBeDisabled()
  30. expect(await panel.textContent()).not.toContain(secret)
  31. await panel.getByRole('button',{name:'添加凭据',exact:true}).click()
  32. await panel.getByRole('combobox',{name:/^用途/}).selectOption('ssh')
  33. await expect(panel.getByRole('combobox',{name:/^SSH 认证方式/})).toHaveValue('password')
  34. await panel.getByLabel('名称',{exact:true}).fill('浏览器测试 SSH 密码')
  35. await panel.getByLabel('用户名',{exact:true}).fill('dummy-ssh-user')
  36. await panel.getByLabel('SSH 主机',{exact:true}).fill('ssh.example.test')
  37. await expect(panel.getByLabel(/^服务器指纹/)).toBeHidden()
  38. await expect(panel.getByLabel(/^服务器指纹/)).not.toHaveAttribute('required')
  39. const sshPassword=panel.getByLabel(/^SSH 登录密码/)
  40. await expect(sshPassword).toHaveAttribute('type','password')
  41. await sshPassword.fill('DUMMY-browser-ssh-password')
  42. await expect(panel.getByText('授权 Bot 后,允许全部远程 SSH 命令并读取输出,不需要预先填写命令。权限由 SSH 账号决定。')).toBeVisible()
  43. await expect(panel.getByLabel('私钥口令(可选)',{exact:true})).toHaveCount(0)
  44. await page.screenshot({path:evidence+'/ssh-password-editor.png'})
  45. await panel.getByRole('button',{name:'加密保存',exact:true}).click()
  46. const sshEntry=panel.locator('li').filter({hasText:'浏览器测试 SSH 密码'})
  47. await expect(sshEntry).toContainText('密码登录')
  48. await expect(panel.getByRole('heading',{name:'授权 Bot 使用',exact:true})).toBeVisible()
  49. expect(await panel.textContent()).not.toContain('DUMMY-browser-ssh-password')
  50. await sshEntry.getByRole('button',{name:'编辑',exact:true}).click()
  51. await expect(sshPassword).toHaveValue('')
  52. await expect(panel.getByRole('combobox',{name:/^SSH 认证方式/})).toHaveValue('password')
  53. await panel.getByText('高级连接设置(可选)',{exact:true}).click()
  54. await expect(panel.getByLabel(/^服务器指纹/)).toHaveValue('')
  55. await expect(panel.getByLabel('端口',{exact:true})).toHaveValue('22')
  56. await panel.getByRole('button',{name:'加密保存',exact:true}).click()
  57. await expect(sshEntry).toContainText('密码登录')
  58. await page.screenshot({path:evidence+'/desktop-saved.png'})
  59. await page.setViewportSize({width:375,height:812})
  60. await expect(panel.getByRole('button',{name:'添加凭据',exact:true})).toBeVisible()
  61. expect(await panel.evaluate(el=>el.scrollWidth<=el.clientWidth)).toBe(true)
  62. await page.screenshot({path:evidence+'/mobile-saved.png'})
  63. await page.setViewportSize({width:1440,height:1000})
  64. await panel.getByRole('button',{name:'锁定管理界面',exact:true}).click()
  65. await expect(panel.getByText('浏览器测试网站',{exact:true})).toHaveCount(0)
  66. await panel.getByLabel('主密码',{exact:true}).fill('DUMMY-wrong-master-password')
  67. await panel.getByRole('button',{name:'手动解锁 5 分钟',exact:true}).click()
  68. await expect(panel.getByRole('alert')).toContainText('主密码错误')
  69. await expect(page.getByRole('textbox',{name:'账号',exact:true})).toHaveCount(0)
  70. await expect(panel.getByLabel('主密码',{exact:true})).toHaveValue('')
  71. await page.screenshot({path:evidence+'/wrong-master-stays-signed-in.png'})
  72. })
  73. test('UI approval survives port edits, closing settings and a full server process restart with real dummy SSH execution',async({page})=>{
  74. const {mkdtempSync,rmSync}=await import('node:fs'),{tmpdir}=await import('node:os'),{join}=await import('node:path'),{spawn}=await import('node:child_process')
  75. const {sshFixture}=await import('../fixtures/credentialProtocols')
  76. const first=await sshFixture('password'),second=await sshFixture('password')
  77. first.state.execReplies.set('uname -s',{stdout:'Linux\n',stderr:''});second.state.execReplies.set('uname -s',{stdout:'Linux\n',stderr:''});second.state.execReplies.set('df -h / | head -n 2',{stdout:'Filesystem Size Used Avail Use% Mounted on\n/dev/test 100G 40G 60G 40% /\n',stderr:''})
  78. const home=mkdtempSync(join(tmpdir(),'yaoyao-vault-browser-restart-')),base='http://127.0.0.1:18837',master='DUMMY-browser-persistent-master'
  79. let child:ReturnType<typeof spawn>|undefined,logs=''
  80. const start=async()=>{
  81. child=spawn(process.execPath,['--import','tsx','tests/fixtures/workspace-server.ts'],{env:{...process.env,WORKSPACE_FIXTURE_HOME:home,WORKSPACE_FIXTURE_PORT:'18837',WORKSPACE_FIXTURE_UPSTREAM_PORT:'19137',WORKSPACE_FIXTURE_VAULT:'1'},stdio:['ignore','pipe','pipe']})
  82. child.stdout!.on('data',c=>logs+=c);child.stderr!.on('data',c=>logs+=c)
  83. await expect.poll(async()=>{try{return (await fetch(base+'/healthz')).status}catch{return 0}},{timeout:20000,message:logs}).toBe(200)
  84. }
  85. const stop=async()=>{if(!child||child.exitCode!==null)return;await page.goto('about:blank').catch(()=>{});const current=child,exited=new Promise<void>(r=>current.once('exit',()=>r()));current.kill('SIGTERM');const timeout=setTimeout(()=>current.kill('SIGKILL'),3000);await exited;clearTimeout(timeout);child=undefined}
  86. const login=async()=>{
  87. await page.goto(base+'/conversations')
  88. if(await page.getByRole('textbox',{name:'账号',exact:true}).isVisible()){
  89. await page.getByRole('textbox',{name:'账号',exact:true}).fill('fixture');await page.getByRole('textbox',{name:'密码',exact:true}).fill('fixture-pass');await page.getByRole('button',{name:'登录',exact:true}).click()
  90. }
  91. await expect(page.locator('.desktop-sidebar .sidebar-account-switcher__main')).toBeVisible()
  92. }
  93. const settings=async()=>{await page.locator('.desktop-sidebar .sidebar-account-switcher__main').click();await page.getByRole('menuitem',{name:'我的设置',exact:true}).click();await page.getByRole('button',{name:'密码管理',exact:true}).click()}
  94. const headers=async()=>({Origin:base,'X-CSRF-Token':(await(await page.request.get(base+'/api/realtime/capabilities')).json()).csrfToken})
  95. try{
  96. await start();await login()
  97. const agentResponse=await page.request.post(base+'/api/app/agents',{headers:await headers(),data:{name:'竹儿测试',profile:'default'}});expect(agentResponse.ok()).toBe(true)
  98. const agentId=(await agentResponse.json()).agent.id
  99. await settings();const panel=page.locator('.credential-vault')
  100. await panel.getByLabel('主密码',{exact:true}).fill(master);await panel.getByLabel('确认主密码',{exact:true}).fill(master);await panel.getByRole('button',{name:'创建密码库',exact:true}).click()
  101. await panel.getByLabel('主密码',{exact:true}).fill(master);await panel.getByRole('button',{name:'手动解锁 5 分钟',exact:true}).click();await panel.getByRole('button',{name:'添加凭据',exact:true}).click()
  102. await panel.getByRole('combobox',{name:/^用途/}).selectOption('ssh')
  103. await panel.getByLabel('名称',{exact:true}).fill('TrueNAS 流程测试');await panel.getByLabel('用户名',{exact:true}).fill(first.username);await panel.getByLabel('SSH 主机',{exact:true}).fill('127.0.0.1');await panel.getByLabel(/^SSH 登录密码/).fill(first.secret)
  104. await panel.getByText('高级连接设置(可选)',{exact:true}).click();await panel.getByLabel('端口',{exact:true}).fill(String(first.target.port))
  105. await panel.getByRole('combobox',{name:/^授权给 Bot/}).selectOption(agentId)
  106. await panel.getByRole('button',{name:'保存并授权 Bot',exact:true}).click();await expect(panel.getByText('TrueNAS 流程测试 → 竹儿测试 · 已持续授权')).toBeVisible()
  107. expect(first.state.authMethods).toEqual([])
  108. const state=await(await page.request.get(base+'/api/app/vault')).json(),credentialRef=state.entries[0].id
  109. const use=async(discoverOnly=false,command='uname -s')=>{const response=await page.request.post(base+'/__test/vault-use',{headers:await headers(),data:{agentId,credentialRef,discoverOnly,...(!discoverOnly?{command}:{})}});expect(response.ok(),await response.text()).toBe(true);return response.json()}
  110. await panel.getByRole('button',{name:'锁定管理界面',exact:true}).click()
  111. expect((await use()).result).toEqual({status:'complete',operation:'ssh.exec',exitCode:0,stdout:'Linux\n',stderr:'',truncated:false});expect(first.state.commands).toEqual(['uname -s'])
  112. await panel.getByLabel('主密码',{exact:true}).fill(master);await panel.getByRole('button',{name:'手动解锁 5 分钟',exact:true}).click()
  113. await panel.locator('li').filter({has:page.locator('strong', {hasText:'TrueNAS 流程测试'})}).getByRole('button',{name:'编辑',exact:true}).click()
  114. await expect(panel.getByText('已持续授权:竹儿测试。修改后继续可用,无需重新授权。')).toBeVisible()
  115. await panel.getByText('高级连接设置(可选)',{exact:true}).click();await panel.getByLabel('端口',{exact:true}).fill(String(second.target.port));await panel.getByRole('button',{name:'加密保存',exact:true}).click()
  116. await page.getByRole('button',{name:'关闭我的设置',exact:true}).filter({visible:true}).first().click();await expect(panel).toHaveCount(0);await expect.poll(async()=>(await(await page.request.get(base+'/api/app/vault')).json()).unlocked).toBe(false)
  117. const edited=await use(false,'df -h / | head -n 2');expect(edited.result.stdout).toContain('40%');expect(edited.refs.references[0]).toMatchObject({authorized:true,allowedTarget:{port:second.target.port}});expect(edited.result.exitCode).toBe(0)
  118. expect(first.state.commands).toHaveLength(1);expect(second.state.commands).toHaveLength(1)
  119. await stop();await start();await login()
  120. const restarted=await(await page.request.get(base+'/api/app/vault')).json();expect(restarted).toMatchObject({unlocked:false,botGrants:[{credentialRef,agentId}]})
  121. expect((await use()).result).toEqual({status:'complete',operation:'ssh.exec',exitCode:0,stdout:'Linux\n',stderr:'',truncated:false});expect(second.state.commands).toHaveLength(2)
  122. await settings();await expect(panel.getByText('TrueNAS 流程测试 → 竹儿测试 · 已持续授权')).toBeVisible()
  123. await page.setViewportSize({width:375,height:812});expect(await panel.evaluate(el=>el.scrollWidth<=el.clientWidth)).toBe(true)
  124. mkdirSync('test-results/credential-vault-persistent',{recursive:true});await page.screenshot({path:'test-results/credential-vault-persistent/mobile-after-restart.png'})
  125. await panel.getByLabel('主密码',{exact:true}).fill(master);await panel.getByRole('button',{name:'手动解锁 5 分钟',exact:true}).click();await panel.getByRole('button',{name:'撤销 Bot 授权',exact:true}).click();await panel.getByRole('button',{name:'锁定管理界面',exact:true}).click()
  126. expect(await use(true)).toMatchObject({references:[],reason:'bot_not_authorized'});expect(second.state.commands).toHaveLength(2)
  127. }finally{await stop();await first.close();await second.close();rmSync(home,{recursive:true,force:true})}
  128. })