localAuthRoutes.test.ts 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146
  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('forces admin/admin to change, then serves shared profiles through the service account', async () => {
  22. const home = mkdtempSync(join(tmpdir(), 'yaoyao-local-routes-'))
  23. roots.push(home)
  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,
  28. superviseDashboard: true,
  29. }
  30. const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => {
  31. const path = new URL(input instanceof Request ? input.url : String(input)).pathname
  32. const headers = new Headers(init?.headers)
  33. if (path === '/api/status') return Response.json({ auth_required: true })
  34. if (path === '/api/auth/me') return headers.get('cookie')
  35. ? Response.json({ user_id: 'service' })
  36. : Response.json({ error: 'unauthorized' }, { status: 401 })
  37. if (path === '/api/auth/providers') return Response.json({ providers: [{ name: 'basic', supports_password: true }] })
  38. if (path === '/auth/password-login') return new Response(JSON.stringify({ ok: true }), {
  39. headers: { 'content-type': 'application/json', 'set-cookie': 'hermes_service=session; Path=/; HttpOnly' },
  40. })
  41. if (path === '/api/profiles') return Response.json({ profiles: [{ name: 'default', is_default: true }] })
  42. if (path === '/api/auth/ws-ticket') return Response.json({ ticket: 'upstream-ticket' })
  43. if (path === '/api/plugins/yaoyao/profiles') return Response.json({ profiles: [] })
  44. return Response.json({ ok: true })
  45. }) as typeof fetch
  46. const runtime = createApplication({ config, fetchImpl })
  47. runtimes.push(runtime)
  48. const agent = request.agent(runtime.app.callback())
  49. const initial = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
  50. const login = await agent.post('/api/app/login')
  51. .set('Host', '127.0.0.1:8800').set('Origin', 'http://127.0.0.1:8800')
  52. .set('X-CSRF-Token', initial.body.csrfToken).send({ username: 'admin', password: 'admin' }).expect(200)
  53. expect(login.body.user).toMatchObject({ username: 'admin', role: 'admin', mustChangePassword: true })
  54. expect(login.body.profiles).toEqual([])
  55. const changed = await agent.put('/api/app/account/credentials')
  56. .set('Host', '127.0.0.1:8800').set('Origin', 'http://127.0.0.1:8800')
  57. .set('X-CSRF-Token', login.body.csrfToken)
  58. .send({ currentPassword: 'admin', newPassword: 'new-password', username: 'owner' }).expect(200)
  59. expect(changed.body.user).toMatchObject({ username: 'owner', mustChangePassword: false })
  60. const ready = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
  61. expect(ready.body).toMatchObject({ upstreamReady: true, serverKind: 'yaoyao-web' })
  62. expect(ready.body.profiles).toHaveLength(1)
  63. await agent.post('/api/app/admin/users')
  64. .set('Host', '127.0.0.1:8800').set('Origin', 'http://127.0.0.1:8800')
  65. .set('X-CSRF-Token', ready.body.csrfToken)
  66. .send({ username: 'member', password: 'temporary-password' }).expect(201)
  67. const users = await agent.get('/api/app/admin/users').set('Host', '127.0.0.1:8800').expect(200)
  68. expect(users.body.items.map((user: { username: string }) => user.username)).toEqual(['owner', 'member'])
  69. expect(cookies(changed)).not.toContain('admin')
  70. const native = request.agent(runtime.app.callback())
  71. await native.get('/api/status').set('Host', '127.0.0.1:8800').expect(200, /yaoyao-web/)
  72. await native.post('/auth/password-login').set('Host', '127.0.0.1:8800')
  73. .send({ provider: 'basic', username: 'owner', password: 'new-password', next: '' }).expect(200)
  74. const identity = await native.get('/api/auth/me').set('Host', '127.0.0.1:8800').expect(200)
  75. expect(identity.body).toMatchObject({ username: 'owner', role: 'admin', server_kind: 'yaoyao-web' })
  76. await native.get('/api/profiles').set('Host', '127.0.0.1:8800').expect(200)
  77. await native.post('/api/auth/ws-ticket').set('Host', '127.0.0.1:8800').send({}).expect(200, /upstream-ticket/)
  78. })
  79. it('lets one 8800 claim another 8800 as a direct child node', async () => {
  80. const registrations: unknown[] = []
  81. const make = (label: string) => {
  82. const home = mkdtempSync(join(tmpdir(), `yaoyao-${label}-`)); roots.push(home)
  83. const config: ServerConfig = {
  84. host: '127.0.0.1', port: 0, upstream: new URL('http://127.0.0.1:9119'),
  85. allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
  86. mediaOwner: 'tester', allowInsecureLan: true, insecureLan: true, production: false,
  87. superviseDashboard: true,
  88. }
  89. const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => {
  90. const path = new URL(input instanceof Request ? input.url : String(input)).pathname
  91. const headers = new Headers(init?.headers)
  92. if (path === '/api/status') return Response.json({ auth_required: true })
  93. if (path === '/api/auth/me') return headers.get('cookie') ? Response.json({ user_id: 'service' }) : Response.json({}, { status: 401 })
  94. if (path === '/api/auth/providers') return Response.json({ providers: [{ name: 'basic', supports_password: true }] })
  95. if (path === '/auth/password-login') return new Response(JSON.stringify({ ok: true }), { headers: { 'content-type': 'application/json', 'set-cookie': `service_${label}=session; Path=/; HttpOnly` } })
  96. if (path === '/api/profiles') return Response.json({ profiles: [{ name: 'default', is_default: true, model: 'test' }] })
  97. if (path === '/api/plugins/yaoyao/profiles') return Response.json({ profiles: [] })
  98. if (path === '/api/plugins/yaoyao/v1/nodes' && init?.method === 'POST') {
  99. registrations.push(JSON.parse(String(init.body))); return Response.json({ nodeId: JSON.parse(String(init.body)).nodeId })
  100. }
  101. return Response.json({ ok: true })
  102. }) as typeof fetch
  103. const app = createApplication({ config, fetchImpl })
  104. const node = createNodeServer(app); nodeRuntimes.push(node)
  105. return { app, node, config }
  106. }
  107. const child = make('child')
  108. const parent = make('parent')
  109. await Promise.all([child, parent].map(({ node, config }) => new Promise<void>(resolve => {
  110. node.server.listen(0, '127.0.0.1', () => {
  111. config.port = (node.server.address() as { port: number }).port
  112. resolve()
  113. })
  114. })))
  115. const authenticate = async (runtime: ApplicationRuntime, port: number) => {
  116. const agent = request.agent(runtime.app.callback())
  117. const initial = await agent.get('/api/app/bootstrap').set('Host', `127.0.0.1:${port}`)
  118. const login = await agent.post('/api/app/login').set('Host', `127.0.0.1:${port}`)
  119. .set('Origin', `http://127.0.0.1:${port}`).set('X-CSRF-Token', initial.body.csrfToken)
  120. .send({ username: 'admin', password: 'admin' })
  121. const changed = await agent.put('/api/app/account/credentials').set('Host', `127.0.0.1:${port}`)
  122. .set('Origin', `http://127.0.0.1:${port}`).set('X-CSRF-Token', login.body.csrfToken)
  123. .send({ currentPassword: 'admin', newPassword: 'new-password', username: 'admin' })
  124. const ready = await agent.get('/api/app/bootstrap').set('Host', `127.0.0.1:${port}`)
  125. return { agent, csrf: ready.body.csrfToken, origin: `http://127.0.0.1:${port}`, changed }
  126. }
  127. const childAuth = await authenticate(child.app, child.config.port)
  128. const parentAuth = await authenticate(parent.app, parent.config.port)
  129. const pairing = await childAuth.agent.post('/api/app/pairings').set('Host', `127.0.0.1:${child.config.port}`)
  130. .set('Origin', childAuth.origin).set('X-CSRF-Token', childAuth.csrf)
  131. .send({ scopes: ['agents.read', 'history.read', 'sessions.execute', 'groups.read', 'groups.execute'] }).expect(201)
  132. await parentAuth.agent.post('/api/app/groups/nodes/pair').set('Host', `127.0.0.1:${parent.config.port}`)
  133. .set('Origin', parentAuth.origin).set('X-CSRF-Token', parentAuth.csrf)
  134. .send({ qrPayload: pairing.body.qrPayload, name: '子 8800' }).expect(201)
  135. expect(registrations).toHaveLength(1)
  136. expect(registrations[0]).toMatchObject({ name: '子 8800', profiles: [{ name: 'default' }] })
  137. })
  138. })