upstreamServiceSession.test.ts 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899
  1. // @vitest-environment node
  2. import { describe, expect, it, vi } from 'vitest'
  3. import { UpstreamServiceSession } from '../../src/server/localAuth.js'
  4. import { CookieJar, UpstreamClient } from '../../src/server/upstream.js'
  5. describe('upstream service validation lease', () => {
  6. function fixture() {
  7. let now = 0, expire = false
  8. const paths: string[] = []
  9. const fetch = vi.fn<typeof globalThis.fetch>(async (input, init) => {
  10. const path = new URL(String(input)).pathname; paths.push(path)
  11. if (path === '/api/status') return Response.json({ auth_required: true })
  12. if (path === '/api/auth/me') return Response.json({ user_id: 'service' })
  13. if (path === '/api/auth/providers') return Response.json({ providers: [] })
  14. if (path === '/auth/password-login') return Response.json({ ok: true }, { headers: { 'set-cookie': 'session=new; Path=/' } })
  15. if (expire && new Headers(init?.headers).get('cookie') === 'session=old') return Response.json({ error: 'expired' }, { status: 401 })
  16. return Response.json({ path })
  17. })
  18. const client = new UpstreamClient(new URL('http://localhost:9119'), fetch, false, () => now)
  19. const session = new UpstreamServiceSession(client, () => ({ username: 'service', password: 'fixture' }), { now: () => now })
  20. session.jar.replace(new CookieJar('session=old'))
  21. return { session, client, fetch, paths, advance: (n: number) => { now += n }, expire: () => { expire = true } }
  22. }
  23. it('validates once for concurrent requests and rechecks only after thirty seconds', async () => {
  24. const f = fixture()
  25. await Promise.all(Array.from({ length: 10 }, (_, i) => f.session.request(`/api/sessions/${i}`)))
  26. expect(f.paths.filter(p => p === '/api/status')).toHaveLength(1)
  27. expect(f.paths.filter(p => p === '/api/auth/me')).toHaveLength(1)
  28. await f.session.request('/api/sessions/a')
  29. expect(f.paths).toHaveLength(13)
  30. f.advance(30_001); await f.session.request('/api/sessions/b')
  31. expect(f.paths.filter(p => p === '/api/status')).toHaveLength(2)
  32. })
  33. it('reduces repeated protected reads to zero upstream calls on a warm hit', async () => {
  34. const f = fixture()
  35. const read = () => f.client.withReadScope('alice', false, () => f.session.request('/api/sessions'))
  36. await Promise.all([read(), read(), read()])
  37. expect(f.paths).toEqual(['/api/status', '/api/auth/me', '/api/sessions'])
  38. await read(); await read()
  39. expect(f.paths).toHaveLength(3)
  40. })
  41. it('repairs parallel 401s once, including raw withJar-style reads', async () => {
  42. const f = fixture(); await f.session.ensure(); f.expire()
  43. await Promise.all([f.client.request('/api/sessions/a', f.session.jar), f.client.request('/api/sessions/b', f.session.jar)])
  44. expect(f.paths.filter(p => p === '/auth/password-login')).toHaveLength(1)
  45. expect(f.paths.filter(p => p === '/api/sessions/a')).toHaveLength(2)
  46. expect(f.paths.filter(p => p === '/api/sessions/b')).toHaveLength(2)
  47. expect(f.session.jar.header).toBe('session=new')
  48. })
  49. it('never replays a write on a timeout or 500 response', async () => {
  50. const f = fixture(); await f.session.ensure()
  51. f.fetch.mockImplementation(async () => Response.json({ error: 'failed' }, { status: 500 }))
  52. const before = f.fetch.mock.calls.length
  53. expect((await f.session.request('/api/sessions/s', { method: 'POST', body: { text: 'once' } })).status).toBe(500)
  54. expect(f.fetch.mock.calls.length - before).toBe(1)
  55. })
  56. it('invalidates validation and cached data on configured credential changes', async () => {
  57. const f = fixture()
  58. let password = 'first'
  59. const session = new UpstreamServiceSession(f.client, () => ({ username: 'service', password }), { now: () => 0 })
  60. await session.ensure()
  61. await f.client.withReadScope('alice', false, () => session.request('/api/sessions'))
  62. expect(f.client.readCache.stats().entries).toBe(1)
  63. password = 'second'; await session.ensure()
  64. expect(f.paths.filter(p => p === '/api/status')).toHaveLength(2)
  65. expect(f.client.readCache.stats().entries).toBe(0)
  66. })
  67. it('does not memoize failed validation', async () => {
  68. const f = fixture()
  69. f.fetch.mockResolvedValueOnce(Response.json({ error: 'offline' }, { status: 503 }))
  70. await expect(f.session.ensure()).rejects.toThrow('9119')
  71. await f.session.ensure()
  72. expect(f.fetch).toHaveBeenCalledTimes(3)
  73. })
  74. it('cannot overwrite new credentials with a late old login response', async () => {
  75. let username = 'old', started = false
  76. let release!: () => void
  77. const blocked = new Promise<void>(r => { release = r })
  78. const fetch = vi.fn<typeof globalThis.fetch>(async (input, init) => {
  79. const path = new URL(String(input)).pathname
  80. if (path === '/api/status') return Response.json({ auth_required: true })
  81. if (path === '/api/auth/me') return Response.json({}, { status: 401 })
  82. if (path === '/api/auth/providers') return Response.json({ providers: [] })
  83. const body = JSON.parse(String(init?.body))
  84. if (body.username === 'old') { started = true; await blocked }
  85. return Response.json({ ok: true }, { headers: { 'set-cookie': `session=${body.username}; Path=/` } })
  86. })
  87. const client = new UpstreamClient(new URL('http://localhost:9119'), fetch)
  88. const session = new UpstreamServiceSession(client, () => ({ username, password: 'fixture' }))
  89. const old = session.ensure().then(() => undefined, error => error)
  90. await vi.waitFor(() => expect(started).toBe(true))
  91. username = 'new'
  92. await session.ensure()
  93. expect(session.jar.header).toBe('session=new')
  94. release()
  95. expect(await old).toMatchObject({ code: 'upstream_credentials_changed' })
  96. expect(session.jar.header).toBe('session=new')
  97. })
  98. })