codexAuthorization.test.ts 4.5 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970
  1. // @vitest-environment node
  2. import {afterEach,beforeEach,expect,it,vi} from 'vitest'
  3. import {EventEmitter} from 'node:events'
  4. import {PassThrough} from 'node:stream'
  5. import {existsSync,statSync,writeFileSync} from 'node:fs'
  6. import {join} from 'node:path'
  7. import {randomUUID} from 'node:crypto'
  8. import {CodexAuthorization,CODEX_DEVICE_URL} from '../../src/server/codexAuthorization'
  9. let auth:CodexAuthorization,version:number,admin:boolean
  10. const processes:Array<{child:any;directory:string;options:any;args:string[]}>=[]
  11. const secret=JSON.stringify({tokens:{access_token:'fixture-access-secret',refresh_token:'fixture-refresh-secret'}})
  12. beforeEach(()=>{
  13. version=1;admin=true;processes.length=0
  14. const launch=vi.fn((_command,args,options)=>{
  15. const child=Object.assign(new EventEmitter(),{stdout:new PassThrough(),stderr:new PassThrough(),exitCode:null,signalCode:null,
  16. kill:vi.fn(()=>{child.signalCode='SIGTERM';child.emit('close',null);return true})})
  17. processes.push({child,directory:options.cwd,options,args});return child
  18. })
  19. auth=new CodexAuthorization({isAdminActive:()=>admin,pushAuthorizationVersion:()=>version} as any,launch as any)
  20. })
  21. afterEach(()=>{auth.close();vi.useRealTimers();vi.unstubAllEnvs()})
  22. function pending(){const id=randomUUID();auth.begin('alice',id);const p=processes.at(-1)!;p.child.stdout.write(`Open ${CODEX_DEVICE_URL}\n\x1b[94mABCD-`);p.child.stdout.write('12345\x1b[0m\n');return {id,...p}}
  23. function complete(p:ReturnType<typeof pending>){writeFileSync(join(p.directory,'auth.json'),secret);p.child.exitCode=0;p.child.emit('close',0)}
  24. it('starts an isolated CLI login, parses split styled output and returns no credentials',()=>{
  25. vi.stubEnv('CODEX_ACCESS_TOKEN','host-secret')
  26. const p=pending()
  27. expect(p.args).toEqual(['login','--device-auth','-c','cli_auth_credentials_store="file"'])
  28. expect(p.options.env.CODEX_HOME).toBe(p.directory);expect(p.options.env.CODEX_ACCESS_TOKEN).toBeUndefined()
  29. expect(statSync(p.directory).mode&0o777).toBe(0o700)
  30. expect(auth.snapshot('alice',p.id)).toMatchObject({status:'pending',loginUrl:CODEX_DEVICE_URL,userCode:'ABCD-12345'})
  31. complete(p)
  32. expect(existsSync(p.directory)).toBe(false)
  33. expect(auth.credentials('alice',p.id)).toBe(secret)
  34. expect(auth.snapshot('alice',p.id)).toEqual({id:p.id,status:'authorized',expiresAt:expect.any(Number)})
  35. })
  36. it('isolates owners and invalidates replaced and cancelled attempts, including late completion',()=>{
  37. const p=pending();expect(()=>auth.credentials('bob',p.id)).toThrow('已结束')
  38. expect(()=>auth.credentials('alice',p.id)).toThrow('请先完成')
  39. auth.begin('alice',p.id);expect(processes).toHaveLength(1)
  40. const next=auth.begin('alice',randomUUID());expect(p.child.kill).toHaveBeenCalled();expect(existsSync(p.directory)).toBe(false)
  41. expect(()=>auth.snapshot('alice',p.id)).toThrow('已结束')
  42. auth.cancel('alice',next.id);expect(auth.snapshot('alice',next.id).status).toBe('cancelled')
  43. p.child.emit('close',0);expect(()=>auth.credentials('alice',next.id)).toThrow('请先完成')
  44. })
  45. it.each(['permission','expiry'])('discards authorized credentials after %s changes',reason=>{
  46. vi.useFakeTimers();const p=pending();complete(p)
  47. if(reason==='permission')version++
  48. else vi.advanceTimersByTime(15*60_000)
  49. expect(()=>auth.credentials('alice',p.id)).toThrow('请先完成')
  50. expect(auth.snapshot('alice',p.id).status).toBe(reason==='permission'?'cancelled':'expired')
  51. })
  52. it('stops an in-flight login when admin rights are removed and ignores late output',()=>{
  53. const p=pending();admin=false
  54. expect(auth.snapshot('alice',p.id).status).toBe('cancelled');expect(p.child.kill).toHaveBeenCalled()
  55. p.child.stdout.write(`\n${CODEX_DEVICE_URL}\nABCD-12345\n`)
  56. expect(auth.snapshot('alice',p.id).userCode).toBeUndefined()
  57. })
  58. it('reports launch and provider failures without exposing raw output',()=>{
  59. const p=pending();p.child.emit('error',Object.assign(new Error('sensitive error'),{code:'ENOENT'}))
  60. expect(auth.snapshot('alice',p.id).error).toContain('未找到 Codex CLI')
  61. const next=pending();next.child.stderr.write('fixture-access-secret');next.child.exitCode=1;next.child.emit('close',1)
  62. expect(auth.snapshot('alice',next.id).status).toBe('failed')
  63. expect(JSON.stringify(auth.snapshot('alice',next.id))).not.toContain('fixture-access-secret')
  64. expect(existsSync(next.directory)).toBe(false)
  65. })
  66. it('does not treat an exit code alone as successful login',()=>{
  67. const p=pending();p.child.exitCode=0;p.child.emit('close',0)
  68. expect(auth.snapshot('alice',p.id).status).toBe('failed')
  69. })