claude-code-best/src/services/SessionMemory/__tests__/multiStore.test.ts
claude-code-best 87f5107024 feat: 添加 Session Memory 多存储支持
Markdown 文件存储的本地记忆系统,支持多 store 管理、
entry 增删改查和归档,存储于 ~/.claude/local-memory/。

Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
2026-05-09 23:04:09 +08:00

309 lines
12 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, test, expect, beforeEach, afterEach } from 'bun:test'
import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
// No mocks needed — multiStore.ts is pure fs, no log/debug/bun:bundle side effects.
describe('multiStore', () => {
let tmpDir: string
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'multi-store-test-'))
process.env['CLAUDE_CONFIG_DIR'] = tmpDir
})
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true })
delete process.env['CLAUDE_CONFIG_DIR']
})
test('listStores returns empty when no stores exist', async () => {
const { listStores } = await import('../multiStore.js')
expect(listStores()).toEqual([])
})
test('createStore creates a store directory', async () => {
const { createStore, listStores } = await import('../multiStore.js')
createStore('my-store')
expect(listStores()).toContain('my-store')
})
test('createStore throws if store already exists', async () => {
const { createStore } = await import('../multiStore.js')
createStore('duplicate')
expect(() => createStore('duplicate')).toThrow('already exists')
})
test('setEntry and getEntry round-trip', async () => {
const { createStore, setEntry, getEntry } = await import('../multiStore.js')
createStore('notes')
setEntry('notes', 'hello', '# Hello\nThis is a note.')
expect(getEntry('notes', 'hello')).toBe('# Hello\nThis is a note.')
})
test('getEntry returns null for missing key', async () => {
const { createStore, getEntry } = await import('../multiStore.js')
createStore('empty-store')
expect(getEntry('empty-store', 'nonexistent')).toBeNull()
})
test('cross-store isolation: entries in different stores do not bleed', async () => {
const { createStore, setEntry, getEntry } = await import('../multiStore.js')
createStore('store-a')
createStore('store-b')
setEntry('store-a', 'shared-key', 'value-from-a')
setEntry('store-b', 'shared-key', 'value-from-b')
expect(getEntry('store-a', 'shared-key')).toBe('value-from-a')
expect(getEntry('store-b', 'shared-key')).toBe('value-from-b')
})
test('listEntries returns keys in a store', async () => {
const { createStore, setEntry, listEntries } = await import(
'../multiStore.js'
)
createStore('listing')
setEntry('listing', 'alpha', 'a')
setEntry('listing', 'beta', 'b')
const entries = listEntries('listing')
expect(entries).toContain('alpha')
expect(entries).toContain('beta')
})
test('deleteEntry removes entry and returns true', async () => {
const { createStore, setEntry, deleteEntry, getEntry } = await import(
'../multiStore.js'
)
createStore('del-store')
setEntry('del-store', 'to-remove', 'temp')
expect(deleteEntry('del-store', 'to-remove')).toBe(true)
expect(getEntry('del-store', 'to-remove')).toBeNull()
})
test('deleteEntry returns false for missing entry', async () => {
const { createStore, deleteEntry } = await import('../multiStore.js')
createStore('del-store-2')
expect(deleteEntry('del-store-2', 'ghost')).toBe(false)
})
test('archiveStore renames directory with .archived suffix', async () => {
const { createStore, archiveStore, listStores, listAllStores } =
await import('../multiStore.js')
createStore('to-archive')
archiveStore('to-archive')
expect(listStores()).not.toContain('to-archive')
expect(listAllStores()).toContain('to-archive.archived')
})
test('large entry round-trip (>500KB)', async () => {
const { createStore, setEntry, getEntry } = await import('../multiStore.js')
createStore('large')
const largeValue = 'A'.repeat(512 * 1024)
setEntry('large', 'big-entry', largeValue)
expect(getEntry('large', 'big-entry')).toBe(largeValue)
})
test('Unicode key is rejected (path-safety policy from PR-0a)', async () => {
const { createStore, setEntry } = await import('../multiStore.js')
createStore('unicode-store')
// Unicode keys are now rejected by validateKey to keep path-safety
// semantics OS-portable and to enable safe permission rule contents.
// Value can still contain unicode — only the key is constrained.
expect(() =>
setEntry('unicode-store', '日本語キー', 'value with 日本語'),
).toThrow(/invalid key chars/i)
})
test('value with unicode is still stored fine (only key is constrained)', async () => {
const { createStore, setEntry, getEntry } = await import('../multiStore.js')
createStore('unicode-value-store')
setEntry('unicode-value-store', 'ascii_key', 'value with 日本語 ✓')
expect(getEntry('unicode-value-store', 'ascii_key')).toBe(
'value with 日本語 ✓',
)
})
test('backward compat: pre-existing a_b.md file remains readable as a_b key', async () => {
// Simulates the pre-PR-0a state where a user wrote setEntry('s', 'a_b', X)
// OR setEntry('s', 'a/b', X) — both produced a_b.md on disk. After PR-0a,
// the new validateKey rejects 'a/b' but accepts 'a_b'. Existing a_b.md
// files must still load via getEntry('s', 'a_b').
const { createStore, getEntry } = await import('../multiStore.js')
createStore('compat-store')
const storeDir = join(tmpDir, 'local-memory', 'compat-store')
writeFileSync(join(storeDir, 'a_b.md'), 'legacy content')
expect(getEntry('compat-store', 'a_b')).toBe('legacy content')
})
test('key collision regression: a/b is rejected, no longer collides with a_b', async () => {
const { createStore, setEntry, getEntry } = await import('../multiStore.js')
createStore('regression-store')
// a_b is valid and stored
setEntry('regression-store', 'a_b', 'value-from-underscore')
// a/b is now rejected (would have collided pre-PR-0a)
expect(() =>
setEntry('regression-store', 'a/b', 'value-from-slash'),
).toThrow(/invalid key chars/i)
// a_b still has the correct value (no overwrite happened)
expect(getEntry('regression-store', 'a_b')).toBe('value-from-underscore')
})
test('Windows reserved name NUL is rejected (would silently lose data on Windows)', async () => {
const { createStore, setEntry } = await import('../multiStore.js')
createStore('win-reserved')
expect(() => setEntry('win-reserved', 'NUL', 'lost')).toThrow(
/windows reserved/i,
)
})
test('leading dot key is rejected (.gitconfig)', async () => {
const { createStore, setEntry } = await import('../multiStore.js')
createStore('hidden-keys')
expect(() => setEntry('hidden-keys', '.gitconfig', 'x')).toThrow(
/leading dot/i,
)
})
})
// ── I3 / E1: Path traversal regression tests ─────────────────────────────────
// All these MUST throw BEFORE the fix lands (they test the invariant that
// invalid store names are rejected before any file I/O occurs).
describe('multiStore: path traversal rejection (E1 regression)', () => {
let tmpDir: string
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'multi-store-sec-'))
process.env['CLAUDE_CONFIG_DIR'] = tmpDir
})
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true })
delete process.env['CLAUDE_CONFIG_DIR']
})
test('store name ".." is rejected', async () => {
const { setEntry } = await import('../multiStore.js')
expect(() => setEntry('..', 'key', 'value')).toThrow()
})
test('store name "a/b" is rejected', async () => {
const { setEntry } = await import('../multiStore.js')
expect(() => setEntry('a/b', 'key', 'value')).toThrow()
})
test('store name "a\\\\b" is rejected', async () => {
const { setEntry } = await import('../multiStore.js')
expect(() => setEntry('a\\b', 'key', 'value')).toThrow()
})
test('store name with null byte is rejected', async () => {
const { setEntry } = await import('../multiStore.js')
expect(() => setEntry('foo\x00bar', 'key', 'value')).toThrow()
})
test('store name "C:hack" (Windows drive prefix) is rejected', async () => {
const { setEntry } = await import('../multiStore.js')
expect(() => setEntry('C:hack', 'key', 'value')).toThrow()
})
test('store name that resolves outside base dir is rejected', async () => {
const { setEntry } = await import('../multiStore.js')
// An encoded-style path that could escape
expect(() => setEntry('../escape', 'key', 'value')).toThrow()
})
test('store name too long (>255 chars) is rejected', async () => {
const { setEntry } = await import('../multiStore.js')
const longName = 'a'.repeat(256)
expect(() => setEntry(longName, 'key', 'value')).toThrow()
})
test('validateStoreName: accepted store name passes', async () => {
const { createStore } = await import('../multiStore.js')
// Should NOT throw
expect(() => createStore('valid-store-name')).not.toThrow()
})
test('D2: value >1MB is rejected', async () => {
const { createStore, setEntry } = await import('../multiStore.js')
createStore('size-test')
const bigValue = 'X'.repeat(1_048_577) // 1MB + 1 byte
expect(() => setEntry('size-test', 'big', bigValue)).toThrow()
})
})
// ── M5 (codecov-100 audit #9): getEntryBounded short-read handling ──────────
// The audit flagged that the old loop returned a `readBytes`-sized buffer
// even if readSync delivered fewer bytes (e.g. file truncated mid-read),
// with `truncated=false`. Test pins the new behavior: short reads surface
// as `truncated=true`, and the returned value's length matches what was
// actually read (no trailing zero bytes).
describe('multiStore: getEntryBounded short-read handling (M5 audit #9)', () => {
let tmpDir: string
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'multi-store-bounded-'))
process.env['CLAUDE_CONFIG_DIR'] = tmpDir
})
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true })
delete process.env['CLAUDE_CONFIG_DIR']
})
test('getEntryBounded: full read with file <= maxBytes returns truncated=false', async () => {
const { createStore, setEntry, getEntryBounded } = await import(
'../multiStore.js'
)
createStore('bounded')
setEntry('bounded', 'small', 'hello')
const result = getEntryBounded('bounded', 'small', 1024)
expect(result).not.toBeNull()
expect(result!.value).toBe('hello')
expect(result!.truncated).toBe(false)
})
test('getEntryBounded: file larger than maxBytes returns truncated=true and prefix only', async () => {
const { createStore, setEntry, getEntryBounded } = await import(
'../multiStore.js'
)
createStore('bounded')
setEntry('bounded', 'big', 'X'.repeat(2048))
const result = getEntryBounded('bounded', 'big', 100)
expect(result).not.toBeNull()
expect(result!.value.length).toBe(100)
expect(result!.value).toBe('X'.repeat(100))
expect(result!.truncated).toBe(true)
})
test('getEntryBounded: returned value has no trailing zero bytes (audit #9 regression)', async () => {
// The old code returned `buf.toString('utf8')` directly — if readSync
// delivered fewer bytes than the buffer was allocated for (statSync
// saw 100 bytes but only 50 were readable by readSync), the returned
// string would have 50 trailing NUL bytes () silently. The new
// code uses subarray(0, offset) so the returned string length matches
// exactly what was read.
const { createStore, setEntry, getEntryBounded } = await import(
'../multiStore.js'
)
createStore('bounded')
setEntry('bounded', 'exact', 'a'.repeat(50))
const result = getEntryBounded('bounded', 'exact', 100)
expect(result).not.toBeNull()
// 50-byte file, read with cap of 100 → readBytes=50, buf is 50 bytes,
// value is exactly 50 bytes with no trailing NULs.
expect(result!.value.length).toBe(50)
expect(result!.value).toBe('a'.repeat(50))
expect(result!.value).not.toContain('')
expect(result!.truncated).toBe(false)
})
test('getEntryBounded: returns null for missing entry', async () => {
const { createStore, getEntryBounded } = await import('../multiStore.js')
createStore('bounded')
expect(getEntryBounded('bounded', 'missing', 1024)).toBeNull()
})
})