subaccountRegistration.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145
  1. // @vitest-environment node
  2. import { mkdtempSync, rmSync } from 'node:fs'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { randomUUID } from 'node:crypto'
  6. import { createServer } from 'node:http'
  7. import { beforeEach, afterEach, describe, expect, it } from 'vitest'
  8. import request from 'supertest'
  9. import { createApplication, type ApplicationRuntime } from '../../src/server/app'
  10. import { loadServerConfig } from '../../src/server/config'
  11. import { LocalAuthStore } from '../../src/server/localAuth'
  12. import { RegistrationLimiter } from '../../src/server/subaccountAccess'
  13. let runtime: ApplicationRuntime, home: string, csrf: string
  14. let admin: ReturnType<typeof request.agent>, child: ReturnType<typeof request.agent>
  15. const host = '127.0.0.1:15300'
  16. const call = (agent: typeof admin, method: 'get'|'post'|'patch'|'head', path: string, token = '') => agent[method](path).set('Host', host).set('Origin', `http://${host}`).set('X-CSRF-Token', token)
  17. beforeEach(async () => {
  18. home = mkdtempSync(join(tmpdir(), 'yaoyao-registration-'))
  19. runtime = createApplication({ config: loadServerConfig({ HERMES_YAOYAO_HOME: home, HERMES_YAOYAO_UPSTREAM: 'http://127.0.0.1:1', HERMES_YAOYAO_ALLOWED_HOSTS: '127.0.0.1' }), fetchImpl: (async input => {
  20. const path = new URL(String(input)).pathname
  21. return Response.json(path === '/api/profiles' ? { profiles: [{ name: 'allowed' }] } : path === '/api/status' ? { auth_required: true } : { user_id: 'service' })
  22. }) as typeof fetch })
  23. admin = request.agent(runtime.app.callback()); child = request.agent(runtime.app.callback())
  24. })
  25. afterEach(() => { runtime.close(); rmSync(home, { recursive: true, force: true }) })
  26. async function setup() {
  27. const boot = await call(admin, 'get', '/api/app/bootstrap')
  28. csrf = (await call(admin, 'post', '/api/app/setup', boot.body.csrfToken).send({ username: 'admin', password: 'admin-password' }).expect(200)).body.csrfToken
  29. }
  30. async function register(username = 'new-child') {
  31. const boot = await call(child, 'get', '/api/app/bootstrap')
  32. const result = await call(child, 'post', '/api/app/register', boot.body.csrfToken).send({ username, password: 'child-password' }).expect(201)
  33. expect(result.body.registrationStatus).toBe('pending')
  34. expect(result.headers['set-cookie']).toBeUndefined()
  35. return boot.body.csrfToken as string
  36. }
  37. describe('subaccount registration and approval through real middleware', () => {
  38. it('keeps initial setup exclusive and validates CSRF, role injection and credentials', async () => {
  39. const boot = await call(child, 'get', '/api/app/bootstrap')
  40. expect(boot.body.registrationAvailable).toBe(false)
  41. await call(child, 'post', '/api/app/register', boot.body.csrfToken).send({ username: 'early', password: 'child-password' }).expect(409)
  42. await setup()
  43. expect((await call(child, 'get', '/api/app/bootstrap')).body.registrationAvailable).toBe(true)
  44. await call(child, 'post', '/api/app/register').send({ username: 'bad', password: 'child-password' }).expect(403)
  45. await call(child, 'post', '/api/app/register', boot.body.csrfToken).send({ username: 'bad', password: 'child-password', role: 'admin', enabled: true }).expect(400)
  46. await call(child, 'post', '/api/app/register', boot.body.csrfToken).send({ username: 'bad', password: 'short' }).expect(400)
  47. await register()
  48. await call(child, 'post', '/api/app/register', boot.body.csrfToken).send({ username: 'NEW-CHILD', password: 'child-password' }).expect(409)
  49. })
  50. it('requires valid assigned profiles to approve; preserves pending status across restart and blocks enable bypass', async () => {
  51. await setup(); const token = await register()
  52. await call(child, 'post', '/api/app/login', token).send({ username: 'new-child', password: 'wrong' }).expect(401)
  53. expect((await call(child, 'post', '/api/app/login', token).send({ username: 'new-child', password: 'child-password' }).expect(403)).body.code).toBe('account_pending_approval')
  54. expect((await call(child, 'post', '/auth/password-login').send({ username: 'new-child', password: 'child-password' }).expect(403)).body.code).toBe('account_pending_approval')
  55. await call(child, 'get', '/api/app/workspace/snapshot').expect(401)
  56. const user = (await call(admin, 'get', '/api/app/admin/users')).body.items.find((u: any) => u.username === 'new-child')
  57. expect(user).toMatchObject({ registrationStatus: 'pending', role: 'user', enabled: false, assignedProfiles: [], mustChangePassword: false })
  58. expect(new LocalAuthStore(home).isUserActive(user.id)).toBe(false)
  59. await call(admin, 'patch', `/api/app/admin/users/${user.id}`, csrf).send({ enabled: true, assignedProfiles: ['allowed'] }).expect(409)
  60. await call(admin, 'post', `/api/app/admin/users/${user.id}/approve`, csrf).send({ assignedProfiles: [] }).expect(400)
  61. await call(admin, 'post', `/api/app/admin/users/${user.id}/approve`, csrf).send({ assignedProfiles: ['missing'] }).expect(409)
  62. await call(child, 'post', `/api/app/admin/users/${user.id}/approve`, token).send({ assignedProfiles: ['allowed'] }).expect(401)
  63. const approved = await call(admin, 'post', `/api/app/admin/users/${user.id}/approve`, csrf).send({ assignedProfiles: ['allowed'] }).expect(200)
  64. expect(approved.body).toMatchObject({ enabled: true, registrationStatus: 'approved', assignedProfiles: ['allowed'], mustChangePassword: false })
  65. await call(admin, 'post', `/api/app/admin/users/${user.id}/approve`, csrf).send({ assignedProfiles: ['allowed'] }).expect(409)
  66. const login = await call(child, 'post', '/api/app/login', token).send({ username: 'new-child', password: 'child-password' }).expect(200)
  67. const create = await call(child, 'post', '/api/app/agents', login.body.csrfToken).send({ name: '我的 Bot', profile: 'allowed' }).expect(201)
  68. const snapshot = (await call(child, 'get', '/api/app/workspace/snapshot').expect(200)).body
  69. expect(snapshot.agents.map((a: any) => a.id)).toEqual([create.body.agent.id])
  70. expect(snapshot.conversations).toHaveLength(1)
  71. expect((await call(admin, 'get', '/api/app/workspace/snapshot')).body.agents).toEqual([])
  72. await call(child, 'get', '/api/app/workspace/projects').expect(200)
  73. await call(child, 'get', '/api/app/workspace/collaboration').expect(200)
  74. await call(child, 'get', '/api/app/workspace/memory-jobs').expect(200)
  75. await call(child, 'post', '/api/app/workspace/snapshot', login.body.csrfToken).send({}).expect(403)
  76. await call(child, 'get', '/api/app/workspace/admin').expect(403)
  77. await call(child, 'get', '/api/app/admin/users').expect(403)
  78. await call(admin, 'patch', `/api/app/admin/users/${user.id}`, csrf).send({ enabled: false }).expect(200)
  79. await call(child, 'get', '/api/app/workspace/snapshot').expect(401)
  80. })
  81. it('bounds registration attempts without changing login or administrator creation', async () => {
  82. await setup()
  83. const token = (await call(child, 'get', '/api/app/bootstrap')).body.csrfToken
  84. for (let i = 0; i < 10; i++) await call(child, 'post', '/api/app/register', token).send({}).expect(400)
  85. const limited = await call(child, 'post', '/api/app/register', token).send({}).expect(429)
  86. expect(Number(limited.headers['retry-after'])).toBeGreaterThan(0)
  87. await call(admin, 'post', '/api/app/admin/users', csrf).send({ username: 'managed', password: 'temporary-password' }).expect(201)
  88. const login = await call(child, 'post', '/api/app/login', token).send({ username: 'managed', password: 'temporary-password' }).expect(200)
  89. expect(login.body.user.mustChangePassword).toBe(true)
  90. const limiter = new RegistrationLimiter()
  91. for (let i = 0; i < 10; i++) expect(limiter.take('ip', 0)).toBe(0)
  92. expect(limiter.take('ip', 1)).toBe(900)
  93. expect(limiter.take('ip', 900_000)).toBe(0)
  94. })
  95. it('authorizes owner-scoped projects, memory, attachments and event replay through the complete middleware', async () => {
  96. await setup(); const token = await register()
  97. const user = (await call(admin, 'get', '/api/app/admin/users')).body.items.find((u: any) => u.username === 'new-child')
  98. await call(admin, 'post', `/api/app/admin/users/${user.id}/approve`, csrf).send({ assignedProfiles: ['allowed'] }).expect(200)
  99. const login = await call(child, 'post', '/api/app/login', token).send({ username: 'new-child', password: 'child-password' }).expect(200)
  100. const childCSRF = login.body.csrfToken
  101. const agent = (await call(child, 'post', '/api/app/agents', childCSRF).send({ name: '知识助手', profile: 'allowed' }).expect(201)).body.agent
  102. await call(child, 'head', '/api/app/workspace/snapshot').expect(200)
  103. const project = (await call(child, 'post', '/api/app/workspace/projects', childCSRF).send({ requestId: randomUUID(), name: '私人项目', memberIds: [agent.id] }).expect(200)).body.project
  104. expect((await call(admin, 'get', '/api/app/workspace/projects')).body.projects).toEqual([])
  105. const scope = { scope: 'project', agentId: agent.id, projectId: project.id }
  106. const memory = (await call(child, 'post', '/api/app/workspace/memories', childCSRF).send({ ...scope, requestId: randomUUID(), content: '只属于此账号的记忆' }).expect(200)).body.memory
  107. const query = new URLSearchParams(scope).toString()
  108. expect((await call(child, 'get', `/api/app/workspace/memories?${query}`).expect(200)).body.memories.map((m: any) => m.id)).toContain(memory.id)
  109. await call(child, 'get', `/api/app/workspace/memories/${memory.id}/revisions?${query}`).expect(200)
  110. expect((await call(child, 'get', `/api/app/workspace/memory-export?${query}`).expect(200)).text).toContain('只属于此账号的记忆')
  111. expect((await call(admin, 'get', `/api/app/workspace/memories?${query}`).expect(200)).body.memories).toEqual([])
  112. await call(admin, 'get', `/api/app/workspace/memories/${memory.id}/revisions?${query}`).expect(404)
  113. await call(child, 'post', '/api/app/workspace/memories/forget', childCSRF).send({ ...scope, id: memory.id, expectedRevision: memory.revision, requestId: randomUUID() }).expect(200)
  114. const file = (await call(child, 'post', '/api/app/uploads', childCSRF).attach('files', Buffer.from('子账号附件'), { filename: 'child.txt', contentType: 'text/plain' }).expect(201)).body.files[0]
  115. await call(child, 'get', `/api/app/files/${file.id}/download`).expect(200)
  116. await call(admin, 'get', `/api/app/files/${file.id}/download`).expect(404)
  117. // Unknown resources reach owner-scoped handlers, rather than the Bot-only gate.
  118. await call(child, 'post', `/api/app/workspace/memory-jobs/${randomUUID()}/retry`, childCSRF).send({}).expect(404)
  119. await call(child, 'post', `/api/app/workspace/collaboration/${randomUUID()}/stop`, childCSRF).send({}).expect(404)
  120. const events = await call(child, 'get', '/api/app/events?after=0').expect(200)
  121. expect(events.body.events.some((e: any) => e.type === 'project.changed')).toBe(true)
  122. const caps = (await call(child, 'get', '/api/app/capabilities')).body.features
  123. for (const feature of ['voice', 'context', 'nodes']) expect(caps).not.toContain(feature)
  124. expect((await call(admin, 'get', '/api/app/capabilities')).body.features).toEqual(expect.arrayContaining(['voice', 'context', 'nodes']))
  125. const server = createServer(runtime.app.callback())
  126. await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
  127. const address = server.address() as { port: number }
  128. const cookie = (login.headers['set-cookie'] as unknown as string[]).map(value => value.split(';')[0]).join('; ')
  129. const abort = new AbortController()
  130. try {
  131. const response = await fetch(`http://127.0.0.1:${address.port}/api/app/events/stream?after=${events.body.cursor}`, { headers: { host, cookie }, signal: AbortSignal.any([abort.signal, AbortSignal.timeout(20_000)]) })
  132. expect(response.status).toBe(200)
  133. expect(response.headers.get('content-type')).toContain('text/event-stream')
  134. const reader = response.body!.getReader(), decoder = new TextDecoder()
  135. let text = ''
  136. while (!text.includes('event: ready')) text += decoder.decode((await reader.read()).value)
  137. await call(admin, 'patch', `/api/app/admin/users/${user.id}`, csrf).send({ enabled: false }).expect(200)
  138. while (!(await reader.read()).done) { /* Revocation closes the old stream no later than its heartbeat. */ }
  139. await call(child, 'get', '/api/app/workspace/snapshot').expect(401)
  140. } finally {
  141. abort.abort(); server.closeAllConnections()
  142. await new Promise<void>(resolve => server.close(() => resolve()))
  143. }
  144. }, 25_000)
  145. })