credentialVaultRoutes.test.ts 5.4 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455
  1. // @vitest-environment node
  2. import { afterEach, expect, it, vi } from 'vitest'
  3. import type Koa from 'koa'
  4. import { mkdtempSync, rmSync } from 'node:fs'
  5. import { tmpdir } from 'node:os'
  6. import { join } from 'node:path'
  7. import request from 'supertest'
  8. import { createApplication, type ApplicationRuntime } from '../../src/server/app'
  9. import { LocalAuthStore } from '../../src/server/localAuth'
  10. import type { ServerConfig } from '../../src/server/config'
  11. const runtimes:ApplicationRuntime[]=[],homes:string[]=[]
  12. function context():Koa.Context{const headers:Record<string,any>={};return {state:{},get:()=>'',set:(k:string,v:any)=>{headers[k.toLowerCase()]=v},response:{headers}} as any}
  13. function cookie(response:any){const values=response.headers['set-cookie'];return (Array.isArray(values)?values:[values]).filter(Boolean).map((v:string)=>v.split(';')[0]).join('; ')}
  14. async function fixture(external=false){
  15. const home=mkdtempSync(join(tmpdir(),'yaoyao-vault-routes-'));homes.push(home)
  16. const auth=new LocalAuthStore(home),login=context();auth.setupAdmin(login,'dummy-admin','DUMMY-only-login-password')
  17. const config={host:'127.0.0.1',port:15300,upstream:new URL('http://127.0.0.1:9119'),allowedHosts:new Set(),home,mediaRoot:join(home,'media'),attachmentsRoot:join(home,'attachments'),imagesRoot:join(home,'images'),mediaOwner:'dummy',allowInsecureLan:false,insecureLan:false,production:true} as ServerConfig
  18. if(external)config.credentialVault={socket:join(home,'missing.sock'),tokenFile:join(home,'missing.token'),hermesUid:process.getuid!()+1}
  19. const fetchImpl=vi.fn(async()=>new Response(JSON.stringify({profiles:['default']}),{headers:{'content-type':'application/json'}})) as unknown as typeof fetch
  20. const runtime=createApplication({config,auth,fetchImpl});runtimes.push(runtime)
  21. const callback=runtime.app.callback(),bootstrap=await request(callback).get('/api/app/bootstrap?inspectOnly=1').set('Host','127.0.0.1:15300').set('Cookie',cookie(login.response)).expect(200)
  22. const cookies=cookie(login.response)+'; '+cookie(bootstrap),token=bootstrap.body.csrfToken
  23. return {runtime,callback,fetchImpl,cookies,token,mutation:(path:string)=>request(callback).post(path).set('Host','127.0.0.1:15300').set('Cookie',cookies).set('Origin','http://127.0.0.1:15300').set('X-CSRF-Token',token)}
  24. }
  25. afterEach(()=>{runtimes.splice(0).forEach(r=>r.close());homes.splice(0).forEach(h=>rmSync(h,{recursive:true,force:true}));vi.restoreAllMocks()})
  26. it('vault routes enforce authentication, exact Origin and CSRF and keep an unavailable explicit broker offline',async()=>{
  27. const f=await fixture(true)
  28. await request(f.callback).get('/api/app/vault').set('Host','127.0.0.1:15300').expect(401)
  29. const status=await request(f.callback).get('/api/app/vault').set('Host','127.0.0.1:15300').set('Cookie',f.cookies).expect(200)
  30. expect(status.body).toMatchObject({online:false,unlocked:false,execution:'disabled',entries:[]});expect(status.headers['cache-control']).toBe('no-store')
  31. await request(f.callback).post('/api/app/vault/unlock').set('Host','127.0.0.1:15300').set('Cookie',f.cookies).set('Origin','http://127.0.0.1:15300').send({password:'DUMMY-master'}).expect(403)
  32. await f.mutation('/api/app/vault/unlock').set('Origin','https://evil.test').send({password:'DUMMY-master'}).expect(403)
  33. const response=await f.mutation('/api/app/vault/unlock').send({password:'DUMMY-master'}).expect(503)
  34. expect(response.body.code).toBe('vault_offline');expect(JSON.stringify(response.body)).not.toContain('DUMMY-master');expect(f.fetchImpl).not.toHaveBeenCalled()
  35. })
  36. it('lets a logged-in administrator create, unlock and add a password in the default installation',async()=>{
  37. const f=await fixture(),password='DUMMY-default-vault-master',secret='DUMMY-default-entry-secret'
  38. const status=()=>request(f.callback).get('/api/app/vault').set('Host','127.0.0.1:15300').set('Cookie',f.cookies)
  39. expect((await status().expect(200)).body).toMatchObject({online:true,initialized:false,storageMode:'local'})
  40. await request(f.callback).post('/api/app/vault/initialize').set('Host','127.0.0.1:15300').set('Cookie',f.cookies).set('Origin','http://127.0.0.1:15300').send({password}).expect(403)
  41. await f.mutation('/api/app/vault/initialize').send({password}).expect(200)
  42. expect((await status().expect(200)).body.unlocked).toBe(false)
  43. await f.mutation('/api/app/vault/unlock').send({password}).expect(200)
  44. const added=await f.mutation('/api/app/vault/entries').send({name:'Default site',username:'dummy',target:{kind:'website',origin:'https://example.test'},secret}).expect(200)
  45. expect(added.body).not.toHaveProperty('secret')
  46. const unlocked=await status().expect(200);expect(unlocked.body.entries).toHaveLength(1);expect(JSON.stringify(unlocked.body)).not.toContain(secret)
  47. await f.mutation('/api/app/vault/lock').send({}).expect(200)
  48. expect((await status().expect(200)).body).toMatchObject({unlocked:false,entries:[]})
  49. expect(f.fetchImpl).not.toHaveBeenCalled()
  50. })
  51. it.each(['/api/app/logout','/auth/logout'])('logout path %s revokes vault before invalidating the authenticated session',async path=>{
  52. const f=await fixture(),logout=vi.spyOn(f.runtime.credentialVault,'logout').mockImplementation(async(owner,c)=>{expect(f.runtime.auth.currentFromCookieHeader(c)?.id).toBe(owner)})
  53. await f.mutation(path).send({}).expect(200);expect(logout).toHaveBeenCalledOnce()
  54. await request(f.callback).get('/api/app/vault').set('Host','127.0.0.1:15300').set('Cookie',f.cookies).expect(401)
  55. })