localAuthRoutes.test.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  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, createNodeServer, type ApplicationRuntime, type NodeServerRuntime } from '../../src/server/app.js'
  7. import type { ServerConfig } from '../../src/server/config.js'
  8. const roots: string[] = []
  9. const runtimes: ApplicationRuntime[] = []
  10. const nodeRuntimes: NodeServerRuntime[] = []
  11. afterEach(async () => {
  12. for (const runtime of nodeRuntimes.splice(0)) await runtime.close()
  13. for (const runtime of runtimes.splice(0)) runtime.close()
  14. for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
  15. })
  16. function cookies(response: request.Response): string {
  17. const values = response.headers['set-cookie'] as unknown as string[] | undefined
  18. return (values ?? []).map(value => value.split(';', 1)[0]).join('; ')
  19. }
  20. describe('8800 local authentication routes', () => {
  21. it('installs without upstream credentials while keeping local login and CSRF mandatory', async () => {
  22. const home = mkdtempSync(join(tmpdir(), 'yaoyao-local-token-')); roots.push(home)
  23. const token = 'fixture_local_only_session_token_123456'
  24. const config: ServerConfig = {
  25. host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'),
  26. allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
  27. mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false, superviseDashboard: true,
  28. }
  29. const runtime = createApplication({ config, fetchImpl: (async (input, init) => {
  30. const path = new URL(String(input)).pathname
  31. if (path === '/api/status') return Response.json({ auth_required: false })
  32. if (path === '/') return new Response(`<script>window.__HERMES_SESSION_TOKEN__="${token}";</script>`)
  33. if (new Headers(init?.headers).get('x-hermes-session-token') !== token) return Response.json({}, { status: 401 })
  34. return Response.json({ profiles: [{ name: 'default', is_default: true, display_name: '丫头' }] })
  35. }) as typeof fetch }); runtimes.push(runtime)
  36. expect(runtime.auth.upstreamCredentials()).toBeUndefined()
  37. const agent = request.agent(runtime.app.callback()), origin = 'http://127.0.0.1:8800'
  38. await agent.get('/api/realtime/capabilities').set('Host', '127.0.0.1:8800').expect(401)
  39. const boot = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
  40. expect(boot.body.authRequired).toBe(true)
  41. const login = await agent.post('/api/app/login').set('Host', '127.0.0.1:8800').set('Origin', origin)
  42. .set('X-CSRF-Token', boot.body.csrfToken).send({ username: 'admin', password: 'admin' }).expect(200)
  43. await agent.put('/api/app/account/credentials').set('Host', '127.0.0.1:8800').set('Origin', origin)
  44. .set('X-CSRF-Token', login.body.csrfToken).send({ currentPassword: 'admin', newPassword: 'fixture-password', username: 'owner' }).expect(200)
  45. const ready = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
  46. expect(ready.body).toMatchObject({ authRequired: true, authenticated: true, upstreamReady: true })
  47. expect(ready.body.profiles).toHaveLength(1)
  48. expect(JSON.stringify([ready.body, ready.headers])).not.toContain(token)
  49. expect(runtime.auth.upstreamCredentials()).toBeUndefined()
  50. const avatar = 'data:image/png;base64,aGVsbG8='
  51. const avatarUpdate = await agent.put('/api/app/account/avatar').set('Host', '127.0.0.1:8800').set('Origin', origin)
  52. .set('X-CSRF-Token', ready.body.csrfToken).send({ avatar }).expect(200)
  53. expect(avatarUpdate.body.user.avatar).toBe(avatar)
  54. expect((await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)).body.user.avatar).toBe(avatar)
  55. expect((await agent.get('/api/auth/me').set('Host', '127.0.0.1:8800').expect(200)).body.avatar).toBe(avatar)
  56. await agent.put('/api/app/account/avatar').set('Host', '127.0.0.1:8800').set('Origin', origin)
  57. .set('X-CSRF-Token', ready.body.csrfToken).send({ avatar: 'https://example.test/avatar.png' }).expect(400)
  58. await agent.post('/api/realtime/channels').set('Host', '127.0.0.1:8800').set('Origin', origin).send({ channel: 'chat' }).expect(403)
  59. await request(runtime.app.callback()).get('/api/realtime/capabilities').set('Host', '127.0.0.1:8800').expect(401)
  60. })
  61. it('forces admin/admin to change, then serves shared profiles through the service account', async () => {
  62. const home = mkdtempSync(join(tmpdir(), 'yaoyao-local-routes-'))
  63. roots.push(home)
  64. const config: ServerConfig = {
  65. host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'),
  66. allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
  67. mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false,
  68. superviseDashboard: true,
  69. upstreamUsername: 'service', upstreamPassword: 'fixture-password',
  70. }
  71. let wsTicketForwardedFor: string | null | undefined
  72. const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => {
  73. const path = new URL(input instanceof Request ? input.url : String(input)).pathname
  74. const headers = new Headers(init?.headers)
  75. if (path === '/api/status') return Response.json({ auth_required: true })
  76. if (path === '/api/auth/me') return headers.get('cookie')
  77. ? Response.json({ user_id: 'service' })
  78. : Response.json({ error: 'unauthorized' }, { status: 401 })
  79. if (path === '/api/auth/providers') return Response.json({ providers: [{ name: 'basic', supports_password: true }] })
  80. if (path === '/auth/password-login') return new Response(JSON.stringify({ ok: true }), {
  81. headers: { 'content-type': 'application/json', 'set-cookie': 'hermes_service=session; Path=/; HttpOnly' },
  82. })
  83. if (path === '/api/profiles') return Response.json({ profiles: [{ name: 'default', is_default: true, display_name: '丫头' }] })
  84. if (path === '/api/auth/ws-ticket') {
  85. wsTicketForwardedFor = headers.get('x-forwarded-for')
  86. return Response.json({ ticket: 'upstream-ticket' })
  87. }
  88. if (path === '/api/plugins/yaoyao/profiles') return Response.json({ profiles: [{ name: 'default', botName: '丫头' }] })
  89. return Response.json({ ok: true })
  90. }) as typeof fetch
  91. const runtime = createApplication({ config, fetchImpl })
  92. runtimes.push(runtime)
  93. const agent = request.agent(runtime.app.callback())
  94. const initial = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
  95. const login = await agent.post('/api/app/login')
  96. .set('Host', '127.0.0.1:8800').set('Origin', 'http://127.0.0.1:8800')
  97. .set('X-CSRF-Token', initial.body.csrfToken).send({ username: 'admin', password: 'admin' }).expect(200)
  98. expect(login.body.user).toMatchObject({ username: 'admin', role: 'admin', mustChangePassword: true })
  99. expect(login.body.profiles).toEqual([])
  100. const changed = await agent.put('/api/app/account/credentials')
  101. .set('Host', '127.0.0.1:8800').set('Origin', 'http://127.0.0.1:8800')
  102. .set('X-CSRF-Token', login.body.csrfToken)
  103. .send({ currentPassword: 'admin', newPassword: 'new-password', username: 'owner' }).expect(200)
  104. expect(changed.body.user).toMatchObject({ username: 'owner', mustChangePassword: false })
  105. const ready = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
  106. expect(ready.body).toMatchObject({ upstreamReady: true, serverKind: 'yaoyao-web' })
  107. expect(ready.body.profiles).toHaveLength(1)
  108. const connection = await agent.get('/api/app/admin/upstream-connection')
  109. .set('Host', '127.0.0.1:8800').expect(200)
  110. expect(connection.body).toMatchObject({
  111. endpoint: 'http://127.0.0.1:9119',
  112. authMode: 'password',
  113. networkScope: 'local',
  114. webNetworkScope: 'local',
  115. ready: true,
  116. })
  117. expect(connection.body.lastVerifiedAt).toEqual(expect.any(Number))
  118. await agent.post('/api/app/admin/users')
  119. .set('Host', '127.0.0.1:8800').set('Origin', 'http://127.0.0.1:8800')
  120. .set('X-CSRF-Token', ready.body.csrfToken)
  121. .send({ username: 'member', password: 'temporary-password' }).expect(201)
  122. const users = await agent.get('/api/app/admin/users').set('Host', '127.0.0.1:8800').expect(200)
  123. expect(users.body.items.map((user: { username: string }) => user.username)).toEqual(['owner', 'member'])
  124. expect(cookies(changed)).not.toContain('admin')
  125. const accountPairing = await agent.post('/api/app/account-pairings')
  126. .set('Host', '127.0.0.1:8800').set('Origin', 'http://127.0.0.1:8800')
  127. .set('X-CSRF-Token', ready.body.csrfToken).send({}).expect(201)
  128. const loginCode = new URL(accountPairing.body.qrPayload)
  129. expect(loginCode.hostname).toBe('login')
  130. const scanned = request.agent(runtime.app.callback())
  131. await scanned.post('/api/account-pair/v1/claim').set('Host', '127.0.0.1:8800')
  132. .send({ pairingId: loginCode.searchParams.get('id'), secret: loginCode.searchParams.get('secret') })
  133. .expect(201)
  134. const scannedIdentity = await scanned.get('/api/auth/me').set('Host', '127.0.0.1:8800').expect(200)
  135. expect(scannedIdentity.body).toMatchObject({ username: 'owner', role: 'admin' })
  136. await request(runtime.app.callback()).post('/api/account-pair/v1/claim')
  137. .set('Host', '127.0.0.1:8800')
  138. .send({ pairingId: loginCode.searchParams.get('id'), secret: loginCode.searchParams.get('secret') })
  139. .expect(409)
  140. const native = request.agent(runtime.app.callback())
  141. await native.get('/api/status').set('Host', '127.0.0.1:8800').expect(200, /yaoyao-web/)
  142. await native.post('/auth/password-login').set('Host', '127.0.0.1:8800')
  143. .send({ provider: 'basic', username: 'owner', password: 'new-password', next: '' }).expect(200)
  144. const identity = await native.get('/api/auth/me').set('Host', '127.0.0.1:8800').expect(200)
  145. expect(identity.body).toMatchObject({ username: 'owner', role: 'admin', server_kind: 'yaoyao-web' })
  146. const nativeProfiles = await native.get('/api/profiles').set('Host', '127.0.0.1:8800').expect(200)
  147. expect(nativeProfiles.body.profiles[0]).toMatchObject({
  148. name: 'default', description: '丫头', display_name: '丫头', agentName: '丫头',
  149. })
  150. await native.post('/api/auth/ws-ticket').set('Host', '127.0.0.1:8800').send({}).expect(410)
  151. expect(wsTicketForwardedFor).toBeUndefined()
  152. })
  153. })