// @vitest-environment node import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { randomUUID } from 'node:crypto' import { createServer } from 'node:http' import { beforeEach, afterEach, describe, expect, it } from 'vitest' import request from 'supertest' import { createApplication, type ApplicationRuntime } from '../../src/server/app' import { loadServerConfig } from '../../src/server/config' import { LocalAuthStore } from '../../src/server/localAuth' import { RegistrationLimiter } from '../../src/server/subaccountAccess' let runtime: ApplicationRuntime, home: string, csrf: string let admin: ReturnType, child: ReturnType const host = '127.0.0.1:15300' 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) beforeEach(async () => { home = mkdtempSync(join(tmpdir(), 'yaoyao-registration-')) 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 => { const path = new URL(String(input)).pathname return Response.json(path === '/api/profiles' ? { profiles: [{ name: 'allowed' }] } : path === '/api/status' ? { auth_required: true } : { user_id: 'service' }) }) as typeof fetch }) admin = request.agent(runtime.app.callback()); child = request.agent(runtime.app.callback()) }) afterEach(() => { runtime.close(); rmSync(home, { recursive: true, force: true }) }) async function setup() { const boot = await call(admin, 'get', '/api/app/bootstrap') csrf = (await call(admin, 'post', '/api/app/setup', boot.body.csrfToken).send({ username: 'admin', password: 'admin-password' }).expect(200)).body.csrfToken } async function register(username = 'new-child') { const boot = await call(child, 'get', '/api/app/bootstrap') const result = await call(child, 'post', '/api/app/register', boot.body.csrfToken).send({ username, password: 'child-password' }).expect(201) expect(result.body.registrationStatus).toBe('pending') expect(result.headers['set-cookie']).toBeUndefined() return boot.body.csrfToken as string } describe('subaccount registration and approval through real middleware', () => { it('keeps initial setup exclusive and validates CSRF, role injection and credentials', async () => { const boot = await call(child, 'get', '/api/app/bootstrap') expect(boot.body.registrationAvailable).toBe(false) await call(child, 'post', '/api/app/register', boot.body.csrfToken).send({ username: 'early', password: 'child-password' }).expect(409) await setup() expect((await call(child, 'get', '/api/app/bootstrap')).body.registrationAvailable).toBe(true) await call(child, 'post', '/api/app/register').send({ username: 'bad', password: 'child-password' }).expect(403) await call(child, 'post', '/api/app/register', boot.body.csrfToken).send({ username: 'bad', password: 'child-password', role: 'admin', enabled: true }).expect(400) await call(child, 'post', '/api/app/register', boot.body.csrfToken).send({ username: 'bad', password: 'short' }).expect(400) await register() await call(child, 'post', '/api/app/register', boot.body.csrfToken).send({ username: 'NEW-CHILD', password: 'child-password' }).expect(409) }) it('requires valid assigned profiles to approve; preserves pending status across restart and blocks enable bypass', async () => { await setup(); const token = await register() await call(child, 'post', '/api/app/login', token).send({ username: 'new-child', password: 'wrong' }).expect(401) expect((await call(child, 'post', '/api/app/login', token).send({ username: 'new-child', password: 'child-password' }).expect(403)).body.code).toBe('account_pending_approval') expect((await call(child, 'post', '/auth/password-login').send({ username: 'new-child', password: 'child-password' }).expect(403)).body.code).toBe('account_pending_approval') await call(child, 'get', '/api/app/workspace/snapshot').expect(401) const user = (await call(admin, 'get', '/api/app/admin/users')).body.items.find((u: any) => u.username === 'new-child') expect(user).toMatchObject({ registrationStatus: 'pending', role: 'user', enabled: false, assignedProfiles: [], mustChangePassword: false }) expect(new LocalAuthStore(home).isUserActive(user.id)).toBe(false) await call(admin, 'patch', `/api/app/admin/users/${user.id}`, csrf).send({ enabled: true, assignedProfiles: ['allowed'] }).expect(409) await call(admin, 'post', `/api/app/admin/users/${user.id}/approve`, csrf).send({ assignedProfiles: [] }).expect(400) await call(admin, 'post', `/api/app/admin/users/${user.id}/approve`, csrf).send({ assignedProfiles: ['missing'] }).expect(409) await call(child, 'post', `/api/app/admin/users/${user.id}/approve`, token).send({ assignedProfiles: ['allowed'] }).expect(401) const approved = await call(admin, 'post', `/api/app/admin/users/${user.id}/approve`, csrf).send({ assignedProfiles: ['allowed'] }).expect(200) expect(approved.body).toMatchObject({ enabled: true, registrationStatus: 'approved', assignedProfiles: ['allowed'], mustChangePassword: false }) await call(admin, 'post', `/api/app/admin/users/${user.id}/approve`, csrf).send({ assignedProfiles: ['allowed'] }).expect(409) const login = await call(child, 'post', '/api/app/login', token).send({ username: 'new-child', password: 'child-password' }).expect(200) const create = await call(child, 'post', '/api/app/agents', login.body.csrfToken).send({ name: '我的 Bot', profile: 'allowed' }).expect(201) const snapshot = (await call(child, 'get', '/api/app/workspace/snapshot').expect(200)).body expect(snapshot.agents.map((a: any) => a.id)).toEqual([create.body.agent.id]) expect(snapshot.conversations).toHaveLength(1) expect((await call(admin, 'get', '/api/app/workspace/snapshot')).body.agents).toEqual([]) // The shared unread centre is owner-scoped, including Bot-only accounts. expect((await call(child, 'get', '/api/app/unread').expect(200)).body.total).toBe(0) await call(child, 'head', '/api/app/unread').expect(200) await call(child, 'post', '/api/app/unread/read').send({ items: [] }).expect(403) await call(child, 'post', '/api/app/unread/read', login.body.csrfToken).send({ items: [] }).expect(200) const adminAgent = (await call(admin, 'post', '/api/app/agents', csrf).send({ name: '管理员 Bot', profile: 'allowed' }).expect(201)).body.agent const adminConversation = (await call(admin, 'get', '/api/app/conversations').expect(200)).body.conversations.find((c: any) => c.memberIds.includes(adminAgent.id)) await call(child, 'post', '/api/app/unread/read', login.body.csrfToken).send({ items: [{ mode: 'bot', id: adminConversation.id, messages: [] }] }).expect(404) await call(child, 'get', '/api/app/workspace/projects').expect(200) await call(child, 'get', '/api/app/workspace/collaboration').expect(200) await call(child, 'get', '/api/app/workspace/memory-jobs').expect(200) await call(child, 'post', '/api/app/workspace/snapshot', login.body.csrfToken).send({}).expect(403) await call(child, 'get', '/api/app/workspace/admin').expect(403) await call(child, 'get', '/api/app/admin/users').expect(403) await call(admin, 'patch', `/api/app/admin/users/${user.id}`, csrf).send({ enabled: false }).expect(200) await call(child, 'get', '/api/app/workspace/snapshot').expect(401) }) it('bounds registration attempts without changing login or administrator creation', async () => { await setup() const token = (await call(child, 'get', '/api/app/bootstrap')).body.csrfToken for (let i = 0; i < 10; i++) await call(child, 'post', '/api/app/register', token).send({}).expect(400) const limited = await call(child, 'post', '/api/app/register', token).send({}).expect(429) expect(Number(limited.headers['retry-after'])).toBeGreaterThan(0) await call(admin, 'post', '/api/app/admin/users', csrf).send({ username: 'managed', password: 'temporary-password' }).expect(201) const login = await call(child, 'post', '/api/app/login', token).send({ username: 'managed', password: 'temporary-password' }).expect(200) expect(login.body.user.mustChangePassword).toBe(true) const limiter = new RegistrationLimiter() for (let i = 0; i < 10; i++) expect(limiter.take('ip', 0)).toBe(0) expect(limiter.take('ip', 1)).toBe(900) expect(limiter.take('ip', 900_000)).toBe(0) }) it('authorizes owner-scoped projects, memory, attachments and event replay through the complete middleware', async () => { await setup(); const token = await register() const user = (await call(admin, 'get', '/api/app/admin/users')).body.items.find((u: any) => u.username === 'new-child') await call(admin, 'post', `/api/app/admin/users/${user.id}/approve`, csrf).send({ assignedProfiles: ['allowed'] }).expect(200) const login = await call(child, 'post', '/api/app/login', token).send({ username: 'new-child', password: 'child-password' }).expect(200) const childCSRF = login.body.csrfToken const agent = (await call(child, 'post', '/api/app/agents', childCSRF).send({ name: '知识助手', profile: 'allowed' }).expect(201)).body.agent await call(child, 'head', '/api/app/workspace/snapshot').expect(200) const project = (await call(child, 'post', '/api/app/workspace/projects', childCSRF).send({ requestId: randomUUID(), name: '私人项目', memberIds: [agent.id] }).expect(200)).body.project expect((await call(admin, 'get', '/api/app/workspace/projects')).body.projects).toEqual([]) const scope = { scope: 'project', agentId: agent.id, projectId: project.id } const memory = (await call(child, 'post', '/api/app/workspace/memories', childCSRF).send({ ...scope, requestId: randomUUID(), content: '只属于此账号的记忆' }).expect(200)).body.memory const query = new URLSearchParams(scope).toString() expect((await call(child, 'get', `/api/app/workspace/memories?${query}`).expect(200)).body.memories.map((m: any) => m.id)).toContain(memory.id) await call(child, 'get', `/api/app/workspace/memories/${memory.id}/revisions?${query}`).expect(200) expect((await call(child, 'get', `/api/app/workspace/memory-export?${query}`).expect(200)).text).toContain('只属于此账号的记忆') expect((await call(admin, 'get', `/api/app/workspace/memories?${query}`).expect(200)).body.memories).toEqual([]) await call(admin, 'get', `/api/app/workspace/memories/${memory.id}/revisions?${query}`).expect(404) await call(child, 'post', '/api/app/workspace/memories/forget', childCSRF).send({ ...scope, id: memory.id, expectedRevision: memory.revision, requestId: randomUUID() }).expect(200) 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] await call(child, 'get', `/api/app/files/${file.id}/download`).expect(200) await call(admin, 'get', `/api/app/files/${file.id}/download`).expect(404) // Unknown resources reach owner-scoped handlers, rather than the Bot-only gate. await call(child, 'post', `/api/app/workspace/memory-jobs/${randomUUID()}/retry`, childCSRF).send({}).expect(404) await call(child, 'post', `/api/app/workspace/collaboration/${randomUUID()}/stop`, childCSRF).send({}).expect(404) const events = await call(child, 'get', '/api/app/events?after=0').expect(200) expect(events.body.events.some((e: any) => e.type === 'project.changed')).toBe(true) const caps = (await call(child, 'get', '/api/app/capabilities')).body.features for (const feature of ['voice', 'context', 'nodes']) expect(caps).not.toContain(feature) expect((await call(admin, 'get', '/api/app/capabilities')).body.features).toEqual(expect.arrayContaining(['voice', 'context', 'nodes'])) const server = createServer(runtime.app.callback()) await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) const address = server.address() as { port: number } const cookie = (login.headers['set-cookie'] as unknown as string[]).map(value => value.split(';')[0]).join('; ') const abort = new AbortController() try { 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)]) }) expect(response.status).toBe(200) expect(response.headers.get('content-type')).toContain('text/event-stream') const reader = response.body!.getReader(), decoder = new TextDecoder() let text = '' while (!text.includes('event: ready')) text += decoder.decode((await reader.read()).value) await call(admin, 'patch', `/api/app/admin/users/${user.id}`, csrf).send({ enabled: false }).expect(200) while (!(await reader.read()).done) { /* Revocation closes the old stream no later than its heartbeat. */ } await call(child, 'get', '/api/app/workspace/snapshot').expect(401) } finally { abort.abort(); server.closeAllConnections() await new Promise(resolve => server.close(() => resolve())) } }, 25_000) })