cc-haha/adapters/common/__tests__/session-store.test.ts
程序员阿江(Relakkes) 1f62bcd919 Prevent IM chats from reusing deleted desktop sessions
Deleting a desktop session removed the transcript but left IM adapter
chat mappings and live WebSocket state able to point at the old session.
The server now closes the active session socket and removes adapter
mappings after deletion, while adapters refresh the shared session store
before reads so a running process cannot reuse stale in-memory data.

Constraint: Adapter session mappings are shared through adapter-sessions.json across long-running IM processes
Rejected: Patch each adapter ensureSession path separately | shared store refresh fixes all current adapters and avoids drift
Confidence: high
Scope-risk: moderate
Directive: Do not cache adapter session mappings without invalidating after server-side session deletion
Tested: bun test common/__tests__/session-store.test.ts common/__tests__/ws-bridge.test.ts
Tested: bun test src/server/__tests__/websocket-handler.test.ts src/server/__tests__/sessions.test.ts --timeout 30000
Tested: cd adapters && bunx tsc --noEmit
Tested: bun run check:adapters
Tested: bun run check:server
Not-tested: bun run verify fails on pre-existing agent-utils coverage ratchet; changed-line coverage is 95.92% and affected lanes pass
Related: https://github.com/NanmiCoder/cc-haha/issues/305
2026-05-08 09:58:24 +08:00

98 lines
3.2 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
import * as fs from 'node:fs'
import * as path from 'node:path'
import * as os from 'node:os'
import { SessionStore } from '../session-store.js'
describe('SessionStore', () => {
let tmpDir: string
let store: SessionStore
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-store-'))
store = new SessionStore(path.join(tmpDir, 'sessions.json'))
})
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true })
})
it('returns null for unknown chatId', () => {
expect(store.get('unknown')).toBeNull()
})
it('stores and retrieves a session', () => {
store.set('chat-1', 'uuid-aaa', '/path/to/project')
const entry = store.get('chat-1')
expect(entry).not.toBeNull()
expect(entry!.sessionId).toBe('uuid-aaa')
expect(entry!.workDir).toBe('/path/to/project')
})
it('overwrites existing entry on set', () => {
store.set('chat-1', 'uuid-aaa', '/old')
store.set('chat-1', 'uuid-bbb', '/new')
expect(store.get('chat-1')!.sessionId).toBe('uuid-bbb')
})
it('deletes an entry', () => {
store.set('chat-1', 'uuid-aaa', '/path')
store.delete('chat-1')
expect(store.get('chat-1')).toBeNull()
})
it('deletes every chat entry bound to a sessionId', () => {
store.set('chat-1', 'uuid-shared', '/project-a')
store.set('chat-2', 'uuid-other', '/project-b')
store.set('chat-3', 'uuid-shared', '/project-c')
const removed = store.deleteBySessionId('uuid-shared')
expect(removed.sort()).toEqual(['chat-1', 'chat-3'])
expect(store.get('chat-1')).toBeNull()
expect(store.get('chat-3')).toBeNull()
expect(store.get('chat-2')!.sessionId).toBe('uuid-other')
const reloaded = new SessionStore(path.join(tmpDir, 'sessions.json'))
expect(reloaded.get('chat-1')).toBeNull()
expect(reloaded.get('chat-3')).toBeNull()
expect(reloaded.get('chat-2')!.sessionId).toBe('uuid-other')
})
it('refreshes from disk before reading so running adapters do not reuse deleted mappings', () => {
store.set('chat-1', 'uuid-stale', '/project')
const serverSideStore = new SessionStore(path.join(tmpDir, 'sessions.json'))
expect(serverSideStore.deleteBySessionId('uuid-stale')).toEqual(['chat-1'])
expect(store.get('chat-1')).toBeNull()
expect(store.listAll()).toEqual([])
})
it('returns an empty list when deleting an unknown sessionId', () => {
store.set('chat-1', 'uuid-aaa', '/project')
expect(store.deleteBySessionId('uuid-missing')).toEqual([])
expect(store.get('chat-1')!.sessionId).toBe('uuid-aaa')
})
it('persists to disk and reloads', () => {
store.set('chat-1', 'uuid-aaa', '/path')
const store2 = new SessionStore(path.join(tmpDir, 'sessions.json'))
expect(store2.get('chat-1')!.sessionId).toBe('uuid-aaa')
})
it('handles missing file gracefully', () => {
const store2 = new SessionStore(path.join(tmpDir, 'nonexistent.json'))
expect(store2.get('anything')).toBeNull()
})
it('lists all entries', () => {
store.set('chat-1', 'uuid-1', '/a')
store.set('chat-2', 'uuid-2', '/b')
const all = store.listAll()
expect(all).toHaveLength(2)
})
})