| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146 |
- // @vitest-environment node
- import { mkdtempSync, rmSync } from 'node:fs'
- import { tmpdir } from 'node:os'
- import { join } from 'node:path'
- import request from 'supertest'
- import { afterEach, describe, expect, it, vi } from 'vitest'
- import { createApplication, type ApplicationRuntime } from '../../src/server/app.js'
- import type { ServerConfig } from '../../src/server/config.js'
- const runtimes: ApplicationRuntime[] = []
- const homes: string[] = []
- const host = '127.0.0.1:15300'
- const origin = `http://${host}`
- const sessionID = 'cached-session'
- afterEach(() => {
- for (const runtime of runtimes.splice(0)) runtime.close()
- for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true })
- })
- async function fixture(upstream: (method: string) => Response) {
- const home = mkdtempSync(join(tmpdir(), 'yaoyao-session-deletion-'))
- homes.push(home)
- const config: ServerConfig = {
- host: '127.0.0.1', port: 15300, upstream: new URL('http://127.0.0.1:19119'),
- allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
- mediaOwner: 'test', allowInsecureLan: false, insecureLan: false, production: false,
- chatCacheMode: 'prefer-local',
- }
- const fetchImpl = vi.fn<typeof fetch>(async (input, init) => {
- const url = new URL(String(input))
- if (url.pathname === '/api/status') return Response.json({ auth_required: false })
- if (url.pathname === '/') return new Response('<script>window.__HERMES_SESSION_TOKEN__="deletion-token-1234567890123456";</script>')
- if (url.pathname === '/api/profiles') return Response.json({ profiles: [{ name: 'default', is_default: true }] })
- if (url.pathname === `/api/sessions/${sessionID}`) return upstream(init?.method ?? 'GET')
- if (url.pathname === '/api/sessions') return Response.json({ sessions: [], total: 0 })
- return Response.json({ error: 'Unknown fixture route' }, { status: 404 })
- })
- const runtime = createApplication({ config, fetchImpl })
- runtimes.push(runtime)
- const agent = request.agent(runtime.app.callback())
- const boot = await agent.get('/api/app/bootstrap').set('Host', host).expect(200)
- const setup = await agent.post('/api/app/setup').set('Host', host).set('Origin', origin)
- .set('X-CSRF-Token', boot.body.csrfToken)
- .send({ username: 'owner', password: 'fixture-password' }).expect(200)
- const owner = String(setup.body.user.id)
- const store = runtime.chatCache!.store
- for (const [account, profile] of [[owner, 'default'], [owner, 'other'], ['other-owner', 'default']]) {
- store.recordRoute(account!, profile!, sessionID, `runtime-${account}-${profile}`)
- store.putSnapshot(account!, `messages-${profile}`, 'messages', profile!, sessionID, {
- status: 200, headers: new Headers({ 'content-type': 'application/json' }),
- body: Buffer.from(JSON.stringify({
- session: { id: sessionID, profile, title: 'Cached history', message_count: 1 },
- messages: [{ id: 'cached-message', role: 'assistant', content: 'Saved answer' }],
- pagination: { total: 1, offset: 0, returned: 1, has_more: false },
- })),
- })
- }
- return {
- runtime, store, owner, fetchImpl, agent,
- remove: () => agent.delete(`/api/app/sessions/${sessionID}?profile=default`)
- .set('Host', host).set('Origin', origin).set('X-CSRF-Token', setup.body.csrfToken),
- assertOtherCopies: () => {
- expect(store.localDetail(owner, 'other', sessionID)).toBeDefined()
- expect(store.messagePage(owner, 'other', sessionID, 0, 100)).toBeDefined()
- expect(store.snapshot(owner, 'messages-other')).toBeDefined()
- expect(store.localDetail('other-owner', 'default', sessionID)).toBeDefined()
- expect(store.messagePage('other-owner', 'default', sessionID, 0, 100)).toBeDefined()
- expect(store.snapshot('other-owner', 'messages-default')).toBeDefined()
- },
- }
- }
- describe('deleting sessions retained only in cache', () => {
- it.each([
- { error: 'Session not found' },
- { error: 'session 不存在' },
- { message: '会话不存在' },
- { detail: 'Session not found: cached-session' },
- { detail: 'Not found' },
- { detail: { message: 'Session not found' } },
- { code: 'session_not_found' },
- ])('cleans a missing session and keeps deletion idempotent: %j', async payload => {
- const f = await fixture(() => Response.json(payload, { status: 404 }))
- const listPath = '/api/app/sessions?view=chat&profile=default'
- const before = await f.agent.get(listPath).set('Host', host).expect(200)
- expect(before.body.sessions.map((session: { id: string }) => session.id)).toEqual([sessionID])
- expect(f.store.messagePage(f.owner, 'default', sessionID, 0, 100)).toBeDefined()
- await f.remove().expect(200, { ok: true })
- await f.remove().expect(200, { ok: true })
- expect(f.store.localDetail(f.owner, 'default', sessionID)).toBeUndefined()
- expect(f.store.messagePage(f.owner, 'default', sessionID, 0, 100)).toBeUndefined()
- expect(f.store.snapshot(f.owner, 'messages-default')).toBeUndefined()
- f.assertOtherCopies()
- const after = await f.agent.get(listPath).set('Host', host).expect(200)
- expect(after.body.sessions).toEqual([])
- const fresh = await f.agent.get(listPath).set('Host', host).set('X-Yaoyao-Cache', 'bypass').expect(200)
- expect(fresh.body.sessions).toEqual([])
- expect(f.fetchImpl.mock.calls.some(([, init]) => init?.method === 'DELETE')).toBe(false)
- })
- it('cleans up when the session disappears between lookup and DELETE', async () => {
- const f = await fixture(method => method === 'DELETE'
- ? Response.json({ error: 'Session not found' }, { status: 404 })
- : Response.json({ id: sessionID, profile: 'default' }))
- await f.remove().expect(200, { ok: true })
- expect(f.store.localDetail(f.owner, 'default', sessionID)).toBeUndefined()
- expect(f.store.messagePage(f.owner, 'default', sessionID, 0, 100)).toBeUndefined()
- f.assertOtherCopies()
- const deletes = f.fetchImpl.mock.calls.filter(([, init]) => init?.method === 'DELETE')
- expect(deletes).toHaveLength(1)
- expect(new URL(String(deletes[0]![0])).searchParams.get('profile')).toBe('default')
- })
- it.each([
- [404, 'Not Found'], [404, 'Profile not found'], [404, 'Session route not found'],
- [401, 'Session not found'], [403, 'Session not found'], [500, 'Session not found'],
- ])('preserves cache on lookup failure %i: %s', async (status, error) => {
- const f = await fixture(() => Response.json({ error }, { status }))
- const response = await f.remove().expect(status === 401 ? 502 : status)
- if (status === 401) expect(response.body).toMatchObject({ code: 'upstream_auth_unavailable' })
- expect(f.store.localDetail(f.owner, 'default', sessionID)).toBeDefined()
- expect(f.store.messagePage(f.owner, 'default', sessionID, 0, 100)).toBeDefined()
- f.assertOtherCopies()
- expect(f.fetchImpl.mock.calls.some(([, init]) => init?.method === 'DELETE')).toBe(false)
- })
- it('preserves cache when DELETE itself fails', async () => {
- const f = await fixture(method => method === 'DELETE'
- ? Response.json({ error: 'Database unavailable' }, { status: 500 })
- : Response.json({ id: sessionID, profile: 'default' }))
- await f.remove().expect(500)
- expect(f.store.localDetail(f.owner, 'default', sessionID)).toBeDefined()
- expect(f.store.messagePage(f.owner, 'default', sessionID, 0, 100)).toBeDefined()
- f.assertOtherCopies()
- })
- it('still rejects upstream mutations of an existing unowned session', async () => {
- const f = await fixture(() => Response.json({ id: sessionID, profile: 'default' }))
- f.store.deleteSession(f.owner, 'default', sessionID)
- await f.remove().expect(410)
- f.assertOtherCopies()
- expect(f.fetchImpl.mock.calls.some(([, init]) => init?.method === 'DELETE')).toBe(false)
- })
- })
|