upstreamServiceSession.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  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') {
  13. return expire && new Headers(init?.headers).get('cookie') === 'session=old'
  14. ? Response.json({ detail: 'expired' }, { status: 401 })
  15. : Response.json({ user_id: 'service' })
  16. }
  17. if (path === '/api/auth/providers') return Response.json({ providers: [] })
  18. if (path === '/auth/password-login') return Response.json({ ok: true }, { headers: { 'set-cookie': 'session=new; Path=/' } })
  19. if (expire && new Headers(init?.headers).get('cookie') === 'session=old') return Response.json({ error: 'expired' }, { status: 401 })
  20. return Response.json({ path })
  21. })
  22. const client = new UpstreamClient(new URL('http://localhost:9119'), fetch, false, () => now)
  23. const session = new UpstreamServiceSession(client, () => ({ username: 'service', password: 'fixture' }), { now: () => now })
  24. session.jar.replace(new CookieJar('session=old'))
  25. return { session, client, fetch, paths, advance: (n: number) => { now += n }, expire: () => { expire = true } }
  26. }
  27. it('validates once for concurrent requests and rechecks only after thirty seconds', async () => {
  28. const f = fixture()
  29. await Promise.all(Array.from({ length: 10 }, (_, i) => f.session.request(`/api/sessions/${i}`)))
  30. expect(f.paths.filter(p => p === '/api/status')).toHaveLength(1)
  31. expect(f.paths.filter(p => p === '/api/auth/me')).toHaveLength(1)
  32. await f.session.request('/api/sessions/a')
  33. expect(f.paths).toHaveLength(13)
  34. f.advance(30_001); await f.session.request('/api/sessions/b')
  35. expect(f.paths.filter(p => p === '/api/status')).toHaveLength(2)
  36. })
  37. it('reduces repeated protected reads to zero upstream calls on a warm hit', async () => {
  38. const f = fixture()
  39. const read = () => f.client.withReadScope('alice', false, () => f.session.request('/api/sessions'))
  40. await Promise.all([read(), read(), read()])
  41. expect(f.paths).toEqual(['/api/status', '/api/auth/me', '/api/sessions'])
  42. await read(); await read()
  43. expect(f.paths).toHaveLength(3)
  44. })
  45. it('repairs parallel 401s once, including raw withJar-style reads', async () => {
  46. const f = fixture(); await f.session.ensure(); f.expire()
  47. await Promise.all([f.client.request('/api/sessions/a', f.session.jar), f.client.request('/api/sessions/b', f.session.jar)])
  48. expect(f.paths.filter(p => p === '/auth/password-login')).toHaveLength(1)
  49. expect(f.paths.filter(p => p === '/api/sessions/a')).toHaveLength(2)
  50. expect(f.paths.filter(p => p === '/api/sessions/b')).toHaveLength(2)
  51. expect(f.session.jar.header).toBe('session=new')
  52. })
  53. it('never replays a write on a timeout or 500 response', async () => {
  54. const f = fixture(); await f.session.ensure()
  55. f.fetch.mockImplementation(async () => Response.json({ error: 'failed' }, { status: 500 }))
  56. const before = f.fetch.mock.calls.length
  57. expect((await f.session.request('/api/sessions/s', { method: 'POST', body: { text: 'once' } })).status).toBe(500)
  58. expect(f.fetch.mock.calls.length - before).toBe(1)
  59. })
  60. it.each(['GET', 'POST'])('reports a persistent service 401 as an upstream failure for %s requests', async method => {
  61. const f = fixture()
  62. await f.session.ensure()
  63. const fetch = f.fetch.getMockImplementation()!
  64. f.fetch.mockImplementation(async (input, init) => new URL(String(input)).pathname === '/api/sessions/rejected'
  65. ? Response.json({ error: 'service session rejected' }, { status: 401 }) : fetch(input, init))
  66. try {
  67. await expect(f.client.request('/api/sessions/rejected', f.session.jar, { method })).rejects.toMatchObject({
  68. status: 502, code: 'upstream_auth_unavailable',
  69. })
  70. expect(f.fetch.mock.calls.filter(([input]) => new URL(String(input)).pathname === '/api/sessions/rejected')).toHaveLength(2)
  71. expect(f.paths.filter(path => path === '/auth/password-login')).toHaveLength(1)
  72. } finally { f.client.close() }
  73. })
  74. it('preserves auth-endpoint 401s for service credential renewal', async () => {
  75. const f = fixture()
  76. await f.session.ensure()
  77. f.fetch.mockResolvedValue(Response.json({ error: 'expired' }, { status: 401 }))
  78. try {
  79. expect((await f.session.request('/api/auth/ws-ticket', { method: 'POST' })).status).toBe(401)
  80. } finally { f.client.close() }
  81. })
  82. it('invalidates validation and cached data on configured credential changes', async () => {
  83. const f = fixture()
  84. let password = 'first'
  85. const session = new UpstreamServiceSession(f.client, () => ({ username: 'service', password }), { now: () => 0 })
  86. await session.ensure()
  87. await f.client.withReadScope('alice', false, () => session.request('/api/sessions'))
  88. expect(f.client.readCache.stats().entries).toBe(1)
  89. password = 'second'; await session.ensure()
  90. expect(f.paths.filter(p => p === '/api/status')).toHaveLength(2)
  91. expect(f.client.readCache.stats().entries).toBe(0)
  92. })
  93. it('does not memoize failed validation', async () => {
  94. const f = fixture()
  95. f.fetch.mockResolvedValueOnce(Response.json({ error: 'offline' }, { status: 503 }))
  96. await expect(f.session.ensure()).rejects.toThrow('9119')
  97. await f.session.ensure()
  98. expect(f.fetch).toHaveBeenCalledTimes(3)
  99. })
  100. it('cannot overwrite new credentials with a late old login response', async () => {
  101. let username = 'old', started = false
  102. let release!: () => void
  103. const blocked = new Promise<void>(r => { release = r })
  104. const fetch = vi.fn<typeof globalThis.fetch>(async (input, init) => {
  105. const path = new URL(String(input)).pathname
  106. if (path === '/api/status') return Response.json({ auth_required: true })
  107. if (path === '/api/auth/me') return Response.json({}, { status: 401 })
  108. if (path === '/api/auth/providers') return Response.json({ providers: [] })
  109. const body = JSON.parse(String(init?.body))
  110. if (body.username === 'old') { started = true; await blocked }
  111. return Response.json({ ok: true }, { headers: { 'set-cookie': `session=${body.username}; Path=/` } })
  112. })
  113. const client = new UpstreamClient(new URL('http://localhost:9119'), fetch)
  114. const session = new UpstreamServiceSession(client, () => ({ username, password: 'fixture' }))
  115. const old = session.ensure().then(() => undefined, error => error)
  116. await vi.waitFor(() => expect(started).toBe(true))
  117. username = 'new'
  118. await session.ensure()
  119. expect(session.jar.header).toBe('session=new')
  120. release()
  121. expect(await old).toMatchObject({ code: 'upstream_credentials_changed' })
  122. expect(session.jar.header).toBe('session=new')
  123. })
  124. it('uses the injected loopback session token without password login', async () => {
  125. const paths: string[] = []
  126. const fetch = vi.fn<typeof globalThis.fetch>(async (input, init) => {
  127. const path = new URL(String(input)).pathname
  128. paths.push(path)
  129. if (path === '/api/status') return Response.json({ auth_required: false })
  130. if (path === '/') {
  131. return new Response('<script>window.__HERMES_SESSION_TOKEN__="loopback-token-123456";</script>', {
  132. headers: { 'content-type': 'text/html' },
  133. })
  134. }
  135. const token = new Headers(init?.headers).get('x-hermes-session-token')
  136. return token === 'loopback-token-123456'
  137. ? Response.json({ ok: true })
  138. : Response.json({ detail: 'Unauthorized' }, { status: 401 })
  139. })
  140. const client = new UpstreamClient(new URL('http://127.0.0.1:9119'), fetch)
  141. const session = new UpstreamServiceSession(client, () => ({ username: 'unused', password: 'unused' }))
  142. expect((await session.request('/api/sessions')).status).toBe(200)
  143. expect(paths).toEqual(['/api/status', '/', '/api/profiles', '/api/sessions'])
  144. expect(session.connectionInfo()).toMatchObject({
  145. endpoint: 'http://127.0.0.1:9119',
  146. authMode: 'loopback-token',
  147. networkScope: 'local',
  148. })
  149. })
  150. it('refreshes a rotated loopback token once and replays parallel reads', async () => {
  151. let activeToken = 'loopback-token-first-fixture'
  152. let pageLoads = 0
  153. const fetch = vi.fn<typeof globalThis.fetch>(async (input, init) => {
  154. const path = new URL(String(input)).pathname
  155. if (path === '/api/status') return Response.json({ auth_required: false })
  156. if (path === '/') {
  157. pageLoads += 1
  158. return new Response(`<script>window.__HERMES_SESSION_TOKEN__=${JSON.stringify(activeToken)};</script>`)
  159. }
  160. return new Headers(init?.headers).get('x-hermes-session-token') === activeToken
  161. ? Response.json({ ok: true })
  162. : Response.json({ detail: 'Unauthorized' }, { status: 401 })
  163. })
  164. const client = new UpstreamClient(new URL('http://127.0.0.1:9119'), fetch)
  165. const session = new UpstreamServiceSession(client, () => undefined)
  166. await session.ensure()
  167. activeToken = 'loopback-token-second-fixture'
  168. const responses = await Promise.all([
  169. client.request('/api/sessions/a', session.jar),
  170. client.request('/api/sessions/b', session.jar),
  171. ])
  172. expect(responses.map(response => response.status)).toEqual([200, 200])
  173. expect(pageLoads).toBe(2)
  174. expect(session.connectionInfo().authMode).toBe('loopback-token')
  175. })
  176. it('refuses token discovery from a non-loopback upstream', async () => {
  177. const fetch = vi.fn<typeof globalThis.fetch>(async () => Response.json({ auth_required: false }))
  178. const client = new UpstreamClient(new URL('http://192.168.1.20:9119'), fetch)
  179. const session = new UpstreamServiceSession(client, () => undefined)
  180. await expect(session.ensure()).rejects.toMatchObject({ code: 'loopback_auth_required' })
  181. expect(fetch).toHaveBeenCalledTimes(1)
  182. })
  183. it('keeps older unauthenticated loopback dashboards compatible when no token is injected', async () => {
  184. const fetch = vi.fn<typeof globalThis.fetch>(async (input) => {
  185. const path = new URL(String(input)).pathname
  186. if (path === '/api/status') return Response.json({ auth_required: false })
  187. if (path === '/') return new Response('<html>legacy dashboard</html>')
  188. return Response.json({ ok: true })
  189. })
  190. const client = new UpstreamClient(new URL('http://127.0.0.1:9119'), fetch)
  191. const session = new UpstreamServiceSession(client, () => undefined)
  192. expect((await session.request('/api/sessions')).status).toBe(200)
  193. expect(session.connectionInfo().authMode).toBe('loopback-direct')
  194. })
  195. })