From 203b51527b2769c8e674549e50429614c0bd82d4 Mon Sep 17 00:00:00 2001 From: catlog22 Date: Mon, 29 Dec 2025 00:05:52 +0800 Subject: [PATCH] test(path-resolver): add unit tests for path resolution and validation Solution-ID: SOL-1735386000002 Issue-ID: ISS-1766921318981-16 Task-ID: T1 --- ccw/tests/path-resolver.test.ts | 283 ++++++++++++++++++++++++++++++++ 1 file changed, 283 insertions(+) create mode 100644 ccw/tests/path-resolver.test.ts diff --git a/ccw/tests/path-resolver.test.ts b/ccw/tests/path-resolver.test.ts new file mode 100644 index 00000000..8254d5ce --- /dev/null +++ b/ccw/tests/path-resolver.test.ts @@ -0,0 +1,283 @@ +/** + * Unit tests for path-resolver utility module. + * + * Notes: + * - Targets the runtime implementation shipped in `ccw/dist`. + * - Uses in-memory stubs for fs + os to avoid touching the real filesystem. + */ + +import { after, beforeEach, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +// Use CJS exports so we can monkeypatch properties before importing the ESM under test. +// eslint-disable-next-line @typescript-eslint/no-var-requires +const fs = require('node:fs') as typeof import('node:fs'); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const os = require('node:os') as typeof import('node:os'); + +const TEST_CCW_HOME = path.join(process.cwd(), '.tmp-ccw-path-resolver-home'); +const ORIGINAL_ENV = { ...process.env }; +process.env.CCW_DATA_DIR = TEST_CCW_HOME; + +type FsState = { + existing: Set; + files: Map; + directories: Set; + realpaths: Map; + mkdirCalls: Array<{ path: string; options: unknown }>; + writeCalls: Array<{ path: string; data: string; encoding: string }>; +}; + +const fsState: FsState = { + existing: new Set(), + files: new Map(), + directories: new Set(), + realpaths: new Map(), + mkdirCalls: [], + writeCalls: [], +}; + +function key(filePath: string): string { + return path.resolve(filePath).replace(/\\/g, '/').toLowerCase(); +} + +function setExists(filePath: string, exists: boolean): void { + const normalized = key(filePath); + if (exists) fsState.existing.add(normalized); + else fsState.existing.delete(normalized); +} + +function setDir(filePath: string, isDirectory: boolean): void { + const normalized = key(filePath); + if (isDirectory) fsState.directories.add(normalized); + else fsState.directories.delete(normalized); +} + +function setFile(filePath: string, content: string): void { + const normalized = key(filePath); + fsState.files.set(normalized, content); + fsState.existing.add(normalized); +} + +function setRealpath(filePath: string, realPath: string): void { + fsState.realpaths.set(key(filePath), realPath); +} + +const originalFs = { + existsSync: fs.existsSync, + readFileSync: fs.readFileSync, + writeFileSync: fs.writeFileSync, + mkdirSync: fs.mkdirSync, + realpathSync: fs.realpathSync, + statSync: fs.statSync, +}; +const originalHomedir = os.homedir; + +const TEST_HOME = path.join(process.cwd(), '.tmp-ccw-path-resolver-user-home'); +os.homedir = () => TEST_HOME; + +fs.existsSync = ((filePath: string) => fsState.existing.has(key(filePath))) as any; +fs.readFileSync = ((filePath: string, encoding: string) => { + assert.equal(encoding, 'utf8'); + const value = fsState.files.get(key(filePath)); + if (value === undefined) { + throw new Error(`ENOENT: no such file or directory, open '${filePath}'`); + } + return value; +}) as any; +fs.writeFileSync = ((filePath: string, data: string, encoding: string) => { + fsState.writeCalls.push({ path: filePath, data: String(data), encoding: String(encoding) }); + setFile(filePath, String(data)); +}) as any; +fs.mkdirSync = ((dirPath: string, options: unknown) => { + fsState.mkdirCalls.push({ path: dirPath, options }); + setExists(dirPath, true); + setDir(dirPath, true); +}) as any; +fs.realpathSync = ((filePath: string) => { + const mapped = fsState.realpaths.get(key(filePath)); + return mapped ?? filePath; +}) as any; +fs.statSync = ((filePath: string) => { + return { + isDirectory: () => fsState.directories.has(key(filePath)), + } as any; +}) as any; + +const pathResolverUrl = new URL('../dist/utils/path-resolver.js', import.meta.url).href; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let pathResolver: any; + +beforeEach(() => { + fsState.existing.clear(); + fsState.files.clear(); + fsState.directories.clear(); + fsState.realpaths.clear(); + fsState.mkdirCalls.length = 0; + fsState.writeCalls.length = 0; +}); + +describe('path-resolver utility module', async () => { + pathResolver = await import(pathResolverUrl); + + it('resolvePath expands ~ and resolves relative paths', () => { + const resolvedHome = pathResolver.resolvePath('~/proj'); + assert.equal(resolvedHome, path.join(TEST_HOME, 'proj')); + + const relativeInput = path.join('some', 'dir'); + assert.equal(pathResolver.resolvePath(relativeInput), path.resolve(relativeInput)); + + assert.equal(pathResolver.resolvePath(''), process.cwd()); + }); + + it('validatePath rejects empty input and control characters', () => { + assert.deepEqual(pathResolver.validatePath(''), { + valid: false, + path: null, + error: 'Path is required', + }); + + const res = pathResolver.validatePath('C:\\allowed\\file.txt\0'); + assert.equal(res.valid, false); + assert.equal(res.path, null); + assert.equal(res.error, 'Path contains invalid characters'); + }); + + it('validatePath enforces baseDir boundary and detects traversal', () => { + const baseDir = 'C:\\allowed'; + const traversal = pathResolver.validatePath('C:\\allowed\\..\\secret', { baseDir }); + assert.equal(traversal.valid, false); + assert.equal(traversal.path, null); + assert.ok(traversal.error?.includes('Path must be within')); + + const outside = pathResolver.validatePath('C:\\secret', { baseDir }); + assert.equal(outside.valid, false); + assert.ok(outside.error?.includes('Path must be within')); + }); + + it('validatePath validates existence when mustExist is set', () => { + const missing = pathResolver.validatePath('C:\\does-not-exist', { mustExist: true }); + assert.equal(missing.valid, false); + assert.equal(missing.path, null); + assert.ok(missing.error?.includes('Path does not exist:')); + }); + + it('validatePath resolves symlinks via realpathSync when path exists', () => { + const baseDir = 'C:\\allowed'; + const linkPath = 'C:\\allowed\\link'; + setExists(linkPath, true); + setRealpath(linkPath, 'C:\\secret'); + + const res = pathResolver.validatePath(linkPath, { baseDir }); + assert.equal(res.valid, false); + assert.equal(res.path, null); + assert.ok(res.error?.includes('Path must be within')); + }); + + it('validateOutputPath rejects directories and resolves relative output paths', () => { + assert.equal(pathResolver.validateOutputPath('').valid, false); + + const out = pathResolver.validateOutputPath('out.txt', 'C:\\base'); + assert.equal(out.valid, true); + assert.equal(out.path, path.resolve(path.join('C:\\base', 'out.txt'))); + + const existingDir = path.resolve('C:\\base\\dir-output'); + setExists(existingDir, true); + setDir(existingDir, true); + const dirRes = pathResolver.validateOutputPath(existingDir, 'C:\\base'); + assert.equal(dirRes.valid, false); + assert.equal(dirRes.error, 'Output path is a directory, expected a file'); + }); + + it('getTemplateLocations and findTemplate use known locations', () => { + const homeTemplates = path.join(TEST_HOME, '.claude', 'templates'); + const cwdTemplates = path.join(process.cwd(), '.claude', 'templates'); + setExists(homeTemplates, true); + setExists(cwdTemplates, false); + + const locations = pathResolver.getTemplateLocations(); + assert.deepEqual(locations, [homeTemplates]); + + const templateName = 'workflow-dashboard.html'; + const templatePath = path.join(homeTemplates, templateName); + setExists(templatePath, true); + + assert.equal(pathResolver.findTemplate(templateName), templatePath); + assert.equal(pathResolver.findTemplate('does-not-exist.html'), null); + }); + + it('ensureDir creates directory only when missing', () => { + const dirPath = 'C:\\tmp\\ensureDir'; + pathResolver.ensureDir(dirPath); + assert.equal(fsState.mkdirCalls.length, 1); + assert.equal(fsState.mkdirCalls[0]?.path, dirPath); + + pathResolver.ensureDir(dirPath); + assert.equal(fsState.mkdirCalls.length, 1); + }); + + it('normalizePathForDisplay converts backslashes to forward slashes', () => { + assert.equal(pathResolver.normalizePathForDisplay('C:\\a\\b'), 'C:/a/b'); + }); + + it('getRecentPaths reads legacy file when legacy exists and new does not', () => { + const legacyFile = path.join(TEST_HOME, '.ccw-recent-paths.json'); + setExists(legacyFile, true); + setFile(legacyFile, JSON.stringify({ paths: ['C:/one', 'C:/two'] })); + + const paths = pathResolver.getRecentPaths(); + assert.deepEqual(paths, ['C:/one', 'C:/two']); + }); + + it('trackRecentPath updates recent list and writes to centralized storage', () => { + const recentFile = path.join(TEST_CCW_HOME, 'config', 'recent-paths.json'); + setExists(recentFile, true); + setFile(recentFile, JSON.stringify({ paths: ['C:/old'] })); + + pathResolver.trackRecentPath('C:\\project\\demo'); + + assert.ok(fsState.writeCalls.length >= 1); + const latest = fsState.writeCalls.at(-1); + assert.equal(latest?.path, recentFile); + + const data = JSON.parse(latest?.data ?? '{}') as { paths?: string[] }; + assert.deepEqual(data.paths?.[0], 'C:/project/demo'); + }); + + it('removeRecentPath removes matching path and persists update', () => { + const recentFile = path.join(TEST_CCW_HOME, 'config', 'recent-paths.json'); + setExists(recentFile, true); + setFile(recentFile, JSON.stringify({ paths: ['C:/one', 'C:/two'] })); + + const removed = pathResolver.removeRecentPath('C:\\two'); + assert.equal(removed, true); + + const latest = fsState.writeCalls.at(-1); + const data = JSON.parse(latest?.data ?? '{}') as { paths?: string[] }; + assert.deepEqual(data.paths, ['C:/one']); + + assert.equal(pathResolver.removeRecentPath('C:\\not-present'), false); + }); +}); + +after(() => { + fs.existsSync = originalFs.existsSync; + fs.readFileSync = originalFs.readFileSync; + fs.writeFileSync = originalFs.writeFileSync; + fs.mkdirSync = originalFs.mkdirSync; + fs.realpathSync = originalFs.realpathSync; + fs.statSync = originalFs.statSync; + os.homedir = originalHomedir; + + for (const key of Object.keys(process.env)) { + if (!(key in ORIGINAL_ENV)) { + delete process.env[key]; + } + } + for (const [key, value] of Object.entries(ORIGINAL_ENV)) { + process.env[key] = value; + } +});