localAuthRoutes.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  1. import { mkdtempSync, rmSync } from 'node:fs'
  2. import { tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import request from 'supertest'
  5. import { afterEach, describe, expect, it } from 'vitest'
  6. import { createApplication, type ApplicationRuntime } from '../../src/server/app.js'
  7. import type { Server } from 'node:http'
  8. import type { ServerConfig } from '../../src/server/config.js'
  9. const roots: string[] = []
  10. const runtimes: ApplicationRuntime[] = []
  11. const servers: Server[] = []
  12. async function listen(runtime: ApplicationRuntime) {
  13. // Keep one listener for the whole scenario. Repeated implicit Supertest
  14. // listen/close cycles can race pooled HTTP connections on recent Node versions.
  15. const server = runtime.app.listen(0, '127.0.0.1'); servers.push(server)
  16. await new Promise<void>(resolve => server.once('listening', resolve))
  17. return server
  18. }
  19. afterEach(async () => {
  20. for (const server of servers.splice(0)) await new Promise<void>((resolve, reject) => {
  21. server.closeAllConnections(); server.close(error => error ? reject(error) : resolve())
  22. })
  23. for (const runtime of runtimes.splice(0)) runtime.close()
  24. for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
  25. })
  26. function cookies(response: request.Response): string {
  27. const values = response.headers['set-cookie'] as unknown as string[] | undefined
  28. return (values ?? []).map(value => value.split(';', 1)[0]).join('; ')
  29. }
  30. describe('15300 local authentication routes', () => {
  31. it('keeps desktop and native account sessions when Hermes rejects its service session', async () => {
  32. const home = mkdtempSync(join(tmpdir(), 'yaoyao-upstream-auth-')); roots.push(home)
  33. const config: ServerConfig = {
  34. host: '127.0.0.1', port: 15300, upstream: new URL('http://127.0.0.1:9119'),
  35. allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
  36. mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: true,
  37. superviseDashboard: true, upstreamUsername: 'service', upstreamPassword: 'fixture-password',
  38. }
  39. let rejected = true, modelRequests = 0
  40. const runtime = createApplication({ config, fetchImpl: async (input, init) => {
  41. const path = new URL(String(input)).pathname
  42. if (path === '/api/status') return Response.json({ auth_required: true })
  43. if (path === '/api/auth/me') return new Headers(init?.headers).get('cookie')
  44. ? Response.json({ user_id: 'service' }) : Response.json({}, { status: 401 })
  45. if (path === '/api/auth/providers') return Response.json({ providers: [] })
  46. if (path === '/auth/password-login') return Response.json({ ok: true }, {
  47. headers: { 'set-cookie': 'hermes_service=fixture; Path=/; HttpOnly' },
  48. })
  49. if (path === '/api/model/options') {
  50. modelRequests += 1
  51. return rejected ? Response.json({ error: 'service login required' }, { status: 401 })
  52. : Response.json({ models: [] })
  53. }
  54. return Response.json({ profiles: [{ name: 'default', is_default: true }] })
  55. } }); runtimes.push(runtime)
  56. const server = await listen(runtime), agent = request.agent(server)
  57. const host = '127.0.0.1:15300'
  58. const boot = await agent.get('/api/app/bootstrap').set('Host', host).expect(200)
  59. const setup = await agent.post('/api/app/setup').set('Host', host).set('Origin', `http://${host}`)
  60. .set('X-CSRF-Token', boot.body.csrfToken).send({ username: 'owner', password: 'fixture-password' }).expect(200)
  61. for (const path of ['/api/app/models', '/api/model/options']) {
  62. const failed = await agent.get(path).set('Host', host).expect(502)
  63. expect(failed.body.code).toBe('upstream_auth_unavailable')
  64. expect(failed.headers['set-cookie']).toBeUndefined()
  65. }
  66. expect(modelRequests).toBe(4)
  67. expect((await agent.get('/api/app/bootstrap?inspectOnly=1').set('Host', host).expect(200)).body)
  68. .toMatchObject({ authenticated: true, user: { id: setup.body.user.id } })
  69. expect((await agent.get('/api/auth/me').set('Host', host).expect(200)).body.user_id).toBe(setup.body.user.id)
  70. rejected = false
  71. await agent.get('/api/app/models').set('Host', host).expect(200)
  72. await request(server).get('/api/app/models').set('Host', host).expect(401)
  73. })
  74. it('installs without upstream credentials while keeping local login and CSRF mandatory', async () => {
  75. const home = mkdtempSync(join(tmpdir(), 'yaoyao-local-token-')); roots.push(home)
  76. const token = 'fixture_local_only_session_token_123456'
  77. const config: ServerConfig = {
  78. host: '127.0.0.1', port: 15300, upstream: new URL('http://127.0.0.1:9119'),
  79. allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
  80. mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false, superviseDashboard: true,
  81. }
  82. const runtime = createApplication({ config, fetchImpl: (async (input, init) => {
  83. const path = new URL(String(input)).pathname
  84. if (path === '/api/status') return Response.json({ auth_required: false })
  85. if (path === '/') return new Response(`<script>window.__HERMES_SESSION_TOKEN__="${token}";</script>`)
  86. if (new Headers(init?.headers).get('x-hermes-session-token') !== token) return Response.json({}, { status: 401 })
  87. return Response.json({ profiles: [{ name: 'default', is_default: true, display_name: '丫头' }] })
  88. }) as typeof fetch }); runtimes.push(runtime)
  89. expect(runtime.auth.upstreamCredentials()).toBeUndefined()
  90. const server = await listen(runtime)
  91. const agent = request.agent(server), origin = 'http://127.0.0.1:15300'
  92. await agent.get('/api/realtime/capabilities').set('Host', '127.0.0.1:15300').expect(401)
  93. const boot = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:15300').expect(200)
  94. expect(boot.body).toMatchObject({ authRequired: true, setupRequired: true })
  95. await agent.post('/api/app/login').set('Host', '127.0.0.1:15300').set('Origin', origin)
  96. .set('X-CSRF-Token', boot.body.csrfToken).send({ username: 'admin', password: 'admin' }).expect(401)
  97. const setup = await agent.post('/api/app/setup').set('Host', '127.0.0.1:15300').set('Origin', origin)
  98. .set('X-CSRF-Token', boot.body.csrfToken).send({ username: 'owner', password: 'fixture-password' }).expect(200)
  99. await agent.post('/api/app/setup').set('Host', '127.0.0.1:15300').set('Origin', origin)
  100. .set('X-CSRF-Token', setup.body.csrfToken).send({ username: 'other', password: 'fixture-password' }).expect(409)
  101. const ready = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:15300').expect(200)
  102. expect(ready.body).toMatchObject({ authRequired: true, authenticated: true, upstreamReady: true })
  103. expect(ready.body.profiles).toHaveLength(1)
  104. expect(JSON.stringify([ready.body, ready.headers])).not.toContain(token)
  105. expect(runtime.auth.upstreamCredentials()).toBeUndefined()
  106. const avatar = 'data:image/png;base64,aGVsbG8='
  107. const avatarUpdate = await agent.put('/api/app/account/avatar').set('Host', '127.0.0.1:15300').set('Origin', origin)
  108. .set('X-CSRF-Token', ready.body.csrfToken).send({ avatar }).expect(200)
  109. expect(avatarUpdate.body.user.avatar).toBe(avatar)
  110. expect((await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:15300').expect(200)).body.user.avatar).toBe(avatar)
  111. expect((await agent.get('/api/auth/me').set('Host', '127.0.0.1:15300').expect(200)).body.avatar).toBe(avatar)
  112. await agent.put('/api/app/account/avatar').set('Host', '127.0.0.1:15300').set('Origin', origin)
  113. .set('X-CSRF-Token', ready.body.csrfToken).send({ avatar: 'https://example.test/avatar.png' }).expect(400)
  114. await agent.post('/api/realtime/channels').set('Host', '127.0.0.1:15300').set('Origin', origin).send({ channel: 'chat' }).expect(403)
  115. await request(server).get('/api/realtime/capabilities').set('Host', '127.0.0.1:15300').expect(401)
  116. })
  117. it('creates an explicit administrator, then serves shared profiles through the service account', async () => {
  118. const home = mkdtempSync(join(tmpdir(), 'yaoyao-local-routes-'))
  119. roots.push(home)
  120. const config: ServerConfig = {
  121. host: '127.0.0.1', port: 15300, upstream: new URL('http://127.0.0.1:9119'),
  122. allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
  123. mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false,
  124. superviseDashboard: true,
  125. upstreamUsername: 'service', upstreamPassword: 'fixture-password',
  126. }
  127. let wsTicketForwardedFor: string | null | undefined
  128. const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => {
  129. const path = new URL(input instanceof Request ? input.url : String(input)).pathname
  130. const headers = new Headers(init?.headers)
  131. if (path === '/api/status') return Response.json({ auth_required: true })
  132. if (path === '/api/auth/me') return headers.get('cookie')
  133. ? Response.json({ user_id: 'service' })
  134. : Response.json({ error: 'unauthorized' }, { status: 401 })
  135. if (path === '/api/auth/providers') return Response.json({ providers: [{ name: 'basic', supports_password: true }] })
  136. if (path === '/auth/password-login') return new Response(JSON.stringify({ ok: true }), {
  137. headers: { 'content-type': 'application/json', 'set-cookie': 'hermes_service=session; Path=/; HttpOnly' },
  138. })
  139. if (path === '/api/profiles') return Response.json({ profiles: [{ name: 'default', is_default: true, display_name: '丫头' }] })
  140. if (path === '/api/auth/ws-ticket') {
  141. wsTicketForwardedFor = headers.get('x-forwarded-for')
  142. return Response.json({ ticket: 'upstream-ticket' })
  143. }
  144. if (path === '/api/plugins/yaoyao/profiles') return Response.json({ profiles: [{ name: 'default', botName: '丫头' }] })
  145. return Response.json({ ok: true })
  146. }) as typeof fetch
  147. const runtime = createApplication({ config, fetchImpl })
  148. runtimes.push(runtime)
  149. const server = await listen(runtime)
  150. const agent = request.agent(server)
  151. const initial = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:15300').expect(200)
  152. const setup = await agent.post('/api/app/setup')
  153. .set('Host', '127.0.0.1:15300').set('Origin', 'http://127.0.0.1:15300')
  154. .set('X-CSRF-Token', initial.body.csrfToken).send({ username: 'owner', password: 'new-password' }).expect(200)
  155. expect(setup.body.user).toMatchObject({ username: 'owner', role: 'admin', mustChangePassword: false })
  156. expect(setup.body.profiles).toHaveLength(1)
  157. const ready = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:15300').expect(200)
  158. expect(ready.body).toMatchObject({ upstreamReady: true, serverKind: 'yaoyao-web' })
  159. expect(ready.body.profiles).toHaveLength(1)
  160. const connection = await agent.get('/api/app/admin/upstream-connection')
  161. .set('Host', '127.0.0.1:15300').expect(200)
  162. expect(connection.body).toMatchObject({
  163. endpoint: 'http://127.0.0.1:9119',
  164. authMode: 'password',
  165. networkScope: 'local',
  166. webNetworkScope: 'local',
  167. ready: true,
  168. })
  169. expect(connection.body.lastVerifiedAt).toEqual(expect.any(Number))
  170. await agent.post('/api/app/admin/users')
  171. .set('Host', '127.0.0.1:15300').set('Origin', 'http://127.0.0.1:15300')
  172. .set('X-CSRF-Token', ready.body.csrfToken)
  173. .send({ username: 'member', password: 'temporary-password' }).expect(201)
  174. const users = await agent.get('/api/app/admin/users').set('Host', '127.0.0.1:15300').expect(200)
  175. expect(users.body.items.map((user: { username: string }) => user.username)).toEqual(['owner', 'member'])
  176. expect(cookies(setup)).not.toContain('admin')
  177. const accountPairing = await agent.post('/api/app/account-pairings')
  178. .set('Host', '127.0.0.1:15300').set('Origin', 'http://127.0.0.1:15300')
  179. .set('X-CSRF-Token', ready.body.csrfToken).send({}).expect(201)
  180. const loginCode = new URL(accountPairing.body.qrPayload)
  181. expect(loginCode.hostname).toBe('login')
  182. const scanned = request.agent(server)
  183. await scanned.post('/api/account-pair/v1/claim').set('Host', '127.0.0.1:15300')
  184. .send({ pairingId: loginCode.searchParams.get('id'), secret: loginCode.searchParams.get('secret') })
  185. .expect(201)
  186. const scannedIdentity = await scanned.get('/api/auth/me').set('Host', '127.0.0.1:15300').expect(200)
  187. expect(scannedIdentity.body).toMatchObject({ username: 'owner', role: 'admin' })
  188. await request(server).post('/api/account-pair/v1/claim')
  189. .set('Host', '127.0.0.1:15300')
  190. .send({ pairingId: loginCode.searchParams.get('id'), secret: loginCode.searchParams.get('secret') })
  191. .expect(409)
  192. const native = request.agent(server)
  193. await native.get('/api/status').set('Host', '127.0.0.1:15300').expect(200, /yaoyao-web/)
  194. await native.post('/auth/password-login').set('Host', '127.0.0.1:15300')
  195. .send({ provider: 'basic', username: 'owner', password: 'new-password', next: '' }).expect(200)
  196. const identity = await native.get('/api/auth/me').set('Host', '127.0.0.1:15300').expect(200)
  197. expect(identity.body).toMatchObject({ username: 'owner', role: 'admin', server_kind: 'yaoyao-web' })
  198. const nativeProfiles = await native.get('/api/profiles').set('Host', '127.0.0.1:15300').expect(200)
  199. expect(nativeProfiles.body.profiles[0]).toMatchObject({
  200. name: 'default', description: '丫头', display_name: '丫头', agentName: '丫头',
  201. })
  202. await native.post('/api/auth/ws-ticket').set('Host', '127.0.0.1:15300').send({}).expect(410)
  203. expect(wsTicketForwardedFor).toBeUndefined()
  204. })
  205. })