workspaceSyncAuthorization.test.ts 3.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172
  1. // @vitest-environment node
  2. import { EventEmitter } from 'node:events'
  3. import { mkdtempSync, rmSync } from 'node:fs'
  4. import { tmpdir } from 'node:os'
  5. import { join } from 'node:path'
  6. import type Koa from 'koa'
  7. import { afterEach, beforeEach, expect, it, vi } from 'vitest'
  8. import { LocalAuthStore } from '../../src/server/localAuth'
  9. import { WorkspaceStore } from '../../src/server/workspaceStore'
  10. import { streamWorkspace } from '../../src/server/workspaceSync'
  11. class Response extends EventEmitter {
  12. writableEnded = false
  13. destroyed = false
  14. writableLength = 0
  15. writes: string[] = []
  16. writeHead() {}
  17. flushHeaders() {}
  18. write(value: string) { this.writes.push(value); return true }
  19. end() { this.writableEnded = true; this.emit('close') }
  20. }
  21. function context(cookie = ''): Koa.Context {
  22. const headers: Record<string, string | string[]> = {}
  23. return { state: {}, response: { headers }, get: (name: string) => name === 'cookie' ? cookie : '',
  24. set: (name: string, value: string | string[]) => { headers[name.toLowerCase()] = value } } as unknown as Koa.Context
  25. }
  26. const issuedCookie = (ctx: Koa.Context) => (ctx.response.headers['set-cookie'] as string[])[0]!.split(';')[0]!
  27. let home: string, store: WorkspaceStore, auth: LocalAuthStore, cookie: string, owner: string, res: Response
  28. beforeEach(() => {
  29. vi.useFakeTimers()
  30. home = mkdtempSync(join(tmpdir(), 'yaoyao-sse-auth-')); store = new WorkspaceStore(home); auth = new LocalAuthStore(home)
  31. const setup = context(); owner = auth.setupAdmin(setup, 'fixture-user', 'fixture-password').id; cookie = issuedCookie(setup)
  32. res = new Response()
  33. const ctx = context(cookie)
  34. // Mirror the authentication middleware's cached request identity.
  35. ctx.state.localUser = auth.require(ctx); ctx.query = {}; ctx.res = res as unknown as Koa.Context['res']
  36. streamWorkspace(ctx, store, auth)
  37. })
  38. afterEach(() => { res.end(); store.close(); rmSync(home, { recursive: true, force: true }); vi.useRealTimers() })
  39. it('closes the cached-user stream and sends no further event after logout', () => {
  40. const version = auth.pushAuthorizationVersion(owner)
  41. auth.logout(context(cookie))
  42. expect(auth.pushAuthorizationVersion(owner)).toBe(version)
  43. expect(auth.currentFromCookieHeader(cookie)).toBeUndefined()
  44. store.event(owner, 'conversation.changed', { id: 'after-logout' })
  45. expect(res.writableEnded).toBe(true)
  46. expect(res.writes.join('')).not.toContain('after-logout')
  47. expect(store.changes.listenerCount(owner)).toBe(0)
  48. })
  49. it('closes an idle expired-session stream on its next heartbeat', () => {
  50. vi.setSystemTime(Date.now() + 31 * 24 * 60 * 60 * 1000)
  51. vi.advanceTimersByTime(15_000)
  52. expect(res.writableEnded).toBe(true)
  53. expect(store.changes.listenerCount(owner)).toBe(0)
  54. })
  55. it('keeps a different valid login stream open when another session logs out', () => {
  56. const other = context(); auth.login(other, 'fixture-user', 'fixture-password')
  57. auth.logout(context(issuedCookie(other)))
  58. store.event(owner, 'conversation.changed', { id: 'still-authorized' })
  59. expect(res.writableEnded).toBe(false)
  60. expect(res.writes.join('')).toContain('still-authorized')
  61. })
  62. it('still closes on account password revocation', () => {
  63. auth.changeCredentials(context(cookie), 'fixture-password', 'fixture-password-changed')
  64. store.event(owner, 'conversation.changed', { id: 'after-password-change' })
  65. expect(res.writableEnded).toBe(true)
  66. expect(res.writes.join('')).not.toContain('after-password-change')
  67. })