Files
session-viewer/tests/unit/session-discovery.test.ts
teernisse eb8001dbf1 Harden session discovery with path validation and parallel I/O
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>
2026-01-30 01:08:57 -05:00

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 });
});
});