sessionDeletion.test.ts 7.6 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 request from 'supertest'
  6. import { afterEach, describe, expect, it, vi } from 'vitest'
  7. import { createApplication, type ApplicationRuntime } from '../../src/server/app.js'
  8. import type { ServerConfig } from '../../src/server/config.js'
  9. const runtimes: ApplicationRuntime[] = []
  10. const homes: string[] = []
  11. const host = '127.0.0.1:15300'
  12. const origin = `http://${host}`
  13. const sessionID = 'cached-session'
  14. afterEach(() => {
  15. for (const runtime of runtimes.splice(0)) runtime.close()
  16. for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true })
  17. })
  18. async function fixture(upstream: (method: string) => Response) {
  19. const home = mkdtempSync(join(tmpdir(), 'yaoyao-session-deletion-'))
  20. homes.push(home)
  21. const config: ServerConfig = {
  22. host: '127.0.0.1', port: 15300, upstream: new URL('http://127.0.0.1:19119'),
  23. allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
  24. mediaOwner: 'test', allowInsecureLan: false, insecureLan: false, production: false,
  25. chatCacheMode: 'prefer-local',
  26. }
  27. const fetchImpl = vi.fn<typeof fetch>(async (input, init) => {
  28. const url = new URL(String(input))
  29. if (url.pathname === '/api/status') return Response.json({ auth_required: false })
  30. if (url.pathname === '/') return new Response('<script>window.__HERMES_SESSION_TOKEN__="deletion-token-1234567890123456";</script>')
  31. if (url.pathname === '/api/profiles') return Response.json({ profiles: [{ name: 'default', is_default: true }] })
  32. if (url.pathname === `/api/sessions/${sessionID}`) return upstream(init?.method ?? 'GET')
  33. if (url.pathname === '/api/sessions') return Response.json({ sessions: [], total: 0 })
  34. return Response.json({ error: 'Unknown fixture route' }, { status: 404 })
  35. })
  36. const runtime = createApplication({ config, fetchImpl })
  37. runtimes.push(runtime)
  38. const agent = request.agent(runtime.app.callback())
  39. const boot = await agent.get('/api/app/bootstrap').set('Host', host).expect(200)
  40. const setup = await agent.post('/api/app/setup').set('Host', host).set('Origin', origin)
  41. .set('X-CSRF-Token', boot.body.csrfToken)
  42. .send({ username: 'owner', password: 'fixture-password' }).expect(200)
  43. const owner = String(setup.body.user.id)
  44. const store = runtime.chatCache!.store
  45. for (const [account, profile] of [[owner, 'default'], [owner, 'other'], ['other-owner', 'default']]) {
  46. store.recordRoute(account!, profile!, sessionID, `runtime-${account}-${profile}`)
  47. store.putSnapshot(account!, `messages-${profile}`, 'messages', profile!, sessionID, {
  48. status: 200, headers: new Headers({ 'content-type': 'application/json' }),
  49. body: Buffer.from(JSON.stringify({
  50. session: { id: sessionID, profile, title: 'Cached history', message_count: 1 },
  51. messages: [{ id: 'cached-message', role: 'assistant', content: 'Saved answer' }],
  52. pagination: { total: 1, offset: 0, returned: 1, has_more: false },
  53. })),
  54. })
  55. }
  56. return {
  57. runtime, store, owner, fetchImpl, agent,
  58. remove: () => agent.delete(`/api/app/sessions/${sessionID}?profile=default`)
  59. .set('Host', host).set('Origin', origin).set('X-CSRF-Token', setup.body.csrfToken),
  60. assertOtherCopies: () => {
  61. expect(store.localDetail(owner, 'other', sessionID)).toBeDefined()
  62. expect(store.messagePage(owner, 'other', sessionID, 0, 100)).toBeDefined()
  63. expect(store.snapshot(owner, 'messages-other')).toBeDefined()
  64. expect(store.localDetail('other-owner', 'default', sessionID)).toBeDefined()
  65. expect(store.messagePage('other-owner', 'default', sessionID, 0, 100)).toBeDefined()
  66. expect(store.snapshot('other-owner', 'messages-default')).toBeDefined()
  67. },
  68. }
  69. }
  70. describe('deleting sessions retained only in cache', () => {
  71. it.each([
  72. { error: 'Session not found' },
  73. { error: 'session 不存在' },
  74. { message: '会话不存在' },
  75. { detail: 'Session not found: cached-session' },
  76. { detail: { message: 'Session not found' } },
  77. { code: 'session_not_found' },
  78. ])('cleans a missing session and keeps deletion idempotent: %j', async payload => {
  79. const f = await fixture(() => Response.json(payload, { status: 404 }))
  80. const listPath = '/api/app/sessions?view=chat&profile=default'
  81. const before = await f.agent.get(listPath).set('Host', host).expect(200)
  82. expect(before.body.sessions.map((session: { id: string }) => session.id)).toEqual([sessionID])
  83. expect(f.store.messagePage(f.owner, 'default', sessionID, 0, 100)).toBeDefined()
  84. await f.remove().expect(200, { ok: true })
  85. await f.remove().expect(200, { ok: true })
  86. expect(f.store.localDetail(f.owner, 'default', sessionID)).toBeUndefined()
  87. expect(f.store.messagePage(f.owner, 'default', sessionID, 0, 100)).toBeUndefined()
  88. expect(f.store.snapshot(f.owner, 'messages-default')).toBeUndefined()
  89. f.assertOtherCopies()
  90. const after = await f.agent.get(listPath).set('Host', host).expect(200)
  91. expect(after.body.sessions).toEqual([])
  92. const fresh = await f.agent.get(listPath).set('Host', host).set('X-Yaoyao-Cache', 'bypass').expect(200)
  93. expect(fresh.body.sessions).toEqual([])
  94. expect(f.fetchImpl.mock.calls.some(([, init]) => init?.method === 'DELETE')).toBe(false)
  95. })
  96. it('cleans up when the session disappears between lookup and DELETE', async () => {
  97. const f = await fixture(method => method === 'DELETE'
  98. ? Response.json({ error: 'Session not found' }, { status: 404 })
  99. : Response.json({ id: sessionID, profile: 'default' }))
  100. await f.remove().expect(200, { ok: true })
  101. expect(f.store.localDetail(f.owner, 'default', sessionID)).toBeUndefined()
  102. expect(f.store.messagePage(f.owner, 'default', sessionID, 0, 100)).toBeUndefined()
  103. f.assertOtherCopies()
  104. const deletes = f.fetchImpl.mock.calls.filter(([, init]) => init?.method === 'DELETE')
  105. expect(deletes).toHaveLength(1)
  106. expect(new URL(String(deletes[0]![0])).searchParams.get('profile')).toBe('default')
  107. })
  108. it.each([
  109. [404, 'Not Found'], [404, 'Profile not found'], [404, 'Session route not found'],
  110. [401, 'Session not found'], [403, 'Session not found'], [500, 'Session not found'],
  111. ])('preserves cache on lookup failure %i: %s', async (status, error) => {
  112. const f = await fixture(() => Response.json({ error }, { status }))
  113. const response = await f.remove().expect(status === 401 ? 502 : status)
  114. if (status === 401) expect(response.body).toMatchObject({ code: 'upstream_auth_unavailable' })
  115. expect(f.store.localDetail(f.owner, 'default', sessionID)).toBeDefined()
  116. expect(f.store.messagePage(f.owner, 'default', sessionID, 0, 100)).toBeDefined()
  117. f.assertOtherCopies()
  118. expect(f.fetchImpl.mock.calls.some(([, init]) => init?.method === 'DELETE')).toBe(false)
  119. })
  120. it('preserves cache when DELETE itself fails', async () => {
  121. const f = await fixture(method => method === 'DELETE'
  122. ? Response.json({ error: 'Database unavailable' }, { status: 500 })
  123. : Response.json({ id: sessionID, profile: 'default' }))
  124. await f.remove().expect(500)
  125. expect(f.store.localDetail(f.owner, 'default', sessionID)).toBeDefined()
  126. expect(f.store.messagePage(f.owner, 'default', sessionID, 0, 100)).toBeDefined()
  127. f.assertOtherCopies()
  128. })
  129. it('still rejects upstream mutations of an existing unowned session', async () => {
  130. const f = await fixture(() => Response.json({ id: sessionID, profile: 'default' }))
  131. f.store.deleteSession(f.owner, 'default', sessionID)
  132. await f.remove().expect(410)
  133. f.assertOtherCopies()
  134. expect(f.fetchImpl.mock.calls.some(([, init]) => init?.method === 'DELETE')).toBe(false)
  135. })
  136. })