Security: Reject session paths containing '..' traversal segments or non-.jsonl extensions before resolving them. This prevents a malicious sessions-index.json from tricking the viewer into reading arbitrary files. Performance: Process all project directories concurrently with Promise.all instead of sequentially awaiting each one. Each directory's stat + readFile is independent I/O that benefits from parallelism. Add test case verifying that traversal paths and non-JSONL paths are rejected while valid paths pass through. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
175 lines
5.7 KiB
TypeScript
175 lines
5.7 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { discoverSessions } from "../../src/server/services/session-discovery.js";
|
|
import path from "path";
|
|
import fs from "fs/promises";
|
|
import os from "os";
|
|
|
|
/** Helper to write a sessions-index.json in the real { version, entries } format */
|
|
function makeIndex(entries: Record<string, unknown>[]) {
|
|
return JSON.stringify({ version: 1, entries });
|
|
}
|
|
|
|
describe("session-discovery", () => {
|
|
it("discovers sessions from { version, entries } format", async () => {
|
|
const tmpDir = path.join(os.tmpdir(), `sv-test-${Date.now()}`);
|
|
const projectDir = path.join(tmpDir, "test-project");
|
|
await fs.mkdir(projectDir, { recursive: true });
|
|
|
|
await fs.writeFile(
|
|
path.join(projectDir, "sessions-index.json"),
|
|
makeIndex([
|
|
{
|
|
sessionId: "sess-001",
|
|
fullPath: "/tmp/sess-001.jsonl",
|
|
summary: "Test session",
|
|
firstPrompt: "Hello",
|
|
created: "2025-10-15T10:00:00Z",
|
|
modified: "2025-10-15T11:00:00Z",
|
|
messageCount: 5,
|
|
},
|
|
])
|
|
);
|
|
|
|
const sessions = await discoverSessions(tmpDir);
|
|
expect(sessions).toHaveLength(1);
|
|
expect(sessions[0].id).toBe("sess-001");
|
|
expect(sessions[0].summary).toBe("Test session");
|
|
expect(sessions[0].project).toBe("test-project");
|
|
expect(sessions[0].messageCount).toBe(5);
|
|
expect(sessions[0].path).toBe("/tmp/sess-001.jsonl");
|
|
|
|
await fs.rm(tmpDir, { recursive: true });
|
|
});
|
|
|
|
it("also handles legacy raw array format", async () => {
|
|
const tmpDir = path.join(os.tmpdir(), `sv-test-legacy-${Date.now()}`);
|
|
const projectDir = path.join(tmpDir, "legacy-project");
|
|
await fs.mkdir(projectDir, { recursive: true });
|
|
|
|
// Raw array (not wrapped in { version, entries })
|
|
await fs.writeFile(
|
|
path.join(projectDir, "sessions-index.json"),
|
|
JSON.stringify([
|
|
{
|
|
sessionId: "legacy-001",
|
|
summary: "Legacy format",
|
|
created: "2025-10-15T10:00:00Z",
|
|
modified: "2025-10-15T11:00:00Z",
|
|
},
|
|
])
|
|
);
|
|
|
|
const sessions = await discoverSessions(tmpDir);
|
|
expect(sessions).toHaveLength(1);
|
|
expect(sessions[0].id).toBe("legacy-001");
|
|
|
|
await fs.rm(tmpDir, { recursive: true });
|
|
});
|
|
|
|
it("handles missing projects directory gracefully", async () => {
|
|
const sessions = await discoverSessions("/nonexistent/path");
|
|
expect(sessions).toEqual([]);
|
|
});
|
|
|
|
it("handles corrupt index files gracefully", async () => {
|
|
const tmpDir = path.join(os.tmpdir(), `sv-test-corrupt-${Date.now()}`);
|
|
const projectDir = path.join(tmpDir, "corrupt-project");
|
|
await fs.mkdir(projectDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(projectDir, "sessions-index.json"),
|
|
"not valid json {"
|
|
);
|
|
|
|
const sessions = await discoverSessions(tmpDir);
|
|
expect(sessions).toEqual([]);
|
|
|
|
await fs.rm(tmpDir, { recursive: true });
|
|
});
|
|
|
|
it("aggregates across multiple project directories", async () => {
|
|
const tmpDir = path.join(os.tmpdir(), `sv-test-multi-${Date.now()}`);
|
|
const proj1 = path.join(tmpDir, "project-a");
|
|
const proj2 = path.join(tmpDir, "project-b");
|
|
await fs.mkdir(proj1, { recursive: true });
|
|
await fs.mkdir(proj2, { recursive: true });
|
|
|
|
await fs.writeFile(
|
|
path.join(proj1, "sessions-index.json"),
|
|
makeIndex([{ sessionId: "a-001", created: "2025-01-01T00:00:00Z", modified: "2025-01-01T00:00:00Z" }])
|
|
);
|
|
await fs.writeFile(
|
|
path.join(proj2, "sessions-index.json"),
|
|
makeIndex([{ sessionId: "b-001", created: "2025-01-02T00:00:00Z", modified: "2025-01-02T00:00:00Z" }])
|
|
);
|
|
|
|
const sessions = await discoverSessions(tmpDir);
|
|
expect(sessions).toHaveLength(2);
|
|
const ids = sessions.map((s) => s.id);
|
|
expect(ids).toContain("a-001");
|
|
expect(ids).toContain("b-001");
|
|
|
|
await fs.rm(tmpDir, { recursive: true });
|
|
});
|
|
|
|
it("rejects paths with traversal segments", async () => {
|
|
const tmpDir = path.join(os.tmpdir(), `sv-test-traversal-${Date.now()}`);
|
|
const projectDir = path.join(tmpDir, "traversal-project");
|
|
await fs.mkdir(projectDir, { recursive: true });
|
|
|
|
await fs.writeFile(
|
|
path.join(projectDir, "sessions-index.json"),
|
|
makeIndex([
|
|
{
|
|
sessionId: "evil-001",
|
|
fullPath: "/home/ubuntu/../../../etc/passwd",
|
|
created: "2025-10-15T10:00:00Z",
|
|
modified: "2025-10-15T11:00:00Z",
|
|
},
|
|
{
|
|
sessionId: "evil-002",
|
|
fullPath: "/home/ubuntu/sessions/not-a-jsonl.txt",
|
|
created: "2025-10-15T10:00:00Z",
|
|
modified: "2025-10-15T11:00:00Z",
|
|
},
|
|
{
|
|
sessionId: "good-001",
|
|
fullPath: "/home/ubuntu/.claude/projects/xyz/good-001.jsonl",
|
|
created: "2025-10-15T10:00:00Z",
|
|
modified: "2025-10-15T11:00:00Z",
|
|
},
|
|
])
|
|
);
|
|
|
|
const sessions = await discoverSessions(tmpDir);
|
|
expect(sessions).toHaveLength(1);
|
|
expect(sessions[0].id).toBe("good-001");
|
|
|
|
await fs.rm(tmpDir, { recursive: true });
|
|
});
|
|
|
|
it("uses fullPath from index entry", async () => {
|
|
const tmpDir = path.join(os.tmpdir(), `sv-test-fp-${Date.now()}`);
|
|
const projectDir = path.join(tmpDir, "fp-project");
|
|
await fs.mkdir(projectDir, { recursive: true });
|
|
|
|
await fs.writeFile(
|
|
path.join(projectDir, "sessions-index.json"),
|
|
makeIndex([
|
|
{
|
|
sessionId: "fp-001",
|
|
fullPath: "/home/ubuntu/.claude/projects/xyz/fp-001.jsonl",
|
|
created: "2025-10-15T10:00:00Z",
|
|
modified: "2025-10-15T11:00:00Z",
|
|
},
|
|
])
|
|
);
|
|
|
|
const sessions = await discoverSessions(tmpDir);
|
|
expect(sessions[0].path).toBe(
|
|
"/home/ubuntu/.claude/projects/xyz/fp-001.jsonl"
|
|
);
|
|
|
|
await fs.rm(tmpDir, { recursive: true });
|
|
});
|
|
});
|