mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 06:16:37 +00:00
410 lines
12 KiB
JavaScript
410 lines
12 KiB
JavaScript
/**
|
|
* Persist Routes Unit Tests
|
|
*
|
|
* Tests backup management endpoints for ~/.claude/settings.json
|
|
*/
|
|
|
|
const assert = require('assert');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
|
|
/**
|
|
* Mock filesystem for isolated testing
|
|
*/
|
|
class MockFs {
|
|
constructor() {
|
|
this.files = new Map();
|
|
this.dirs = new Set();
|
|
}
|
|
|
|
reset() {
|
|
this.files.clear();
|
|
this.dirs.clear();
|
|
}
|
|
|
|
addDir(dirPath) {
|
|
this.dirs.add(dirPath);
|
|
}
|
|
|
|
addFile(filePath, content) {
|
|
this.files.set(filePath, { content, isSymlink: false });
|
|
this.addDir(path.dirname(filePath));
|
|
}
|
|
|
|
addSymlink(filePath) {
|
|
this.files.set(filePath, { content: '', isSymlink: true });
|
|
this.addDir(path.dirname(filePath));
|
|
}
|
|
|
|
existsSync(p) {
|
|
return this.files.has(p) || this.dirs.has(p);
|
|
}
|
|
|
|
readdirSync(dir) {
|
|
const result = [];
|
|
for (const filePath of this.files.keys()) {
|
|
if (path.dirname(filePath) === dir) {
|
|
result.push(path.basename(filePath));
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
lstatSync(p) {
|
|
const file = this.files.get(p);
|
|
if (!file) throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
|
return {
|
|
isSymbolicLink: () => file.isSymlink,
|
|
size: file.content.length,
|
|
};
|
|
}
|
|
|
|
readFileSync(p) {
|
|
const file = this.files.get(p);
|
|
if (!file) throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
|
return file.content;
|
|
}
|
|
|
|
openSync(p, _mode) {
|
|
if (!this.files.has(p)) throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
|
return 1; // Mock fd
|
|
}
|
|
|
|
readSync(fd, buffer, offset, length, position) {
|
|
// Simple mock - copy content to buffer
|
|
const file = this.files.values().next().value;
|
|
const content = Buffer.from(file.content);
|
|
content.copy(buffer, offset, position, Math.min(position + length, content.length));
|
|
return Math.min(length, content.length);
|
|
}
|
|
|
|
closeSync() {
|
|
// No-op for mock
|
|
}
|
|
}
|
|
|
|
describe('Persist Routes', function () {
|
|
describe('Backup File Parsing', function () {
|
|
it('should match valid backup filename pattern', function () {
|
|
const pattern = /^settings\.json\.backup\.(\d{8}_\d{6})$/;
|
|
|
|
// Valid patterns
|
|
assert.ok(pattern.test('settings.json.backup.20250110_143022'));
|
|
assert.ok(pattern.test('settings.json.backup.19990101_000000'));
|
|
assert.ok(pattern.test('settings.json.backup.20301231_235959'));
|
|
|
|
// Invalid patterns
|
|
assert.ok(!pattern.test('settings.json.backup.2025011_143022')); // 7 digits date
|
|
assert.ok(!pattern.test('settings.json.backup.20250110_14302')); // 5 digits time
|
|
assert.ok(!pattern.test('settings.json.backup'));
|
|
assert.ok(!pattern.test('settings.json'));
|
|
assert.ok(!pattern.test('backup.20250110_143022'));
|
|
});
|
|
|
|
it('should extract timestamp from backup filename', function () {
|
|
const pattern = /^settings\.json\.backup\.(\d{8}_\d{6})$/;
|
|
const match = 'settings.json.backup.20250110_143022'.match(pattern);
|
|
|
|
assert.ok(match);
|
|
assert.strictEqual(match[1], '20250110_143022');
|
|
});
|
|
|
|
it('should parse timestamp to Date correctly', function () {
|
|
const timestamp = '20250110_143022';
|
|
|
|
const year = parseInt(timestamp.slice(0, 4));
|
|
const month = parseInt(timestamp.slice(4, 6)) - 1; // 0-indexed
|
|
const day = parseInt(timestamp.slice(6, 8));
|
|
const hour = parseInt(timestamp.slice(9, 11));
|
|
const min = parseInt(timestamp.slice(11, 13));
|
|
const sec = parseInt(timestamp.slice(13, 15));
|
|
|
|
const date = new Date(year, month, day, hour, min, sec);
|
|
|
|
assert.strictEqual(date.getFullYear(), 2025);
|
|
assert.strictEqual(date.getMonth(), 0); // January
|
|
assert.strictEqual(date.getDate(), 10);
|
|
assert.strictEqual(date.getHours(), 14);
|
|
assert.strictEqual(date.getMinutes(), 30);
|
|
assert.strictEqual(date.getSeconds(), 22);
|
|
});
|
|
});
|
|
|
|
describe('Backup Sorting', function () {
|
|
it('should sort backups by date (newest first)', function () {
|
|
const backups = [
|
|
{ timestamp: '20250108_100000', date: new Date(2025, 0, 8, 10, 0, 0) },
|
|
{ timestamp: '20250110_143022', date: new Date(2025, 0, 10, 14, 30, 22) },
|
|
{ timestamp: '20250109_120000', date: new Date(2025, 0, 9, 12, 0, 0) },
|
|
];
|
|
|
|
const sorted = backups.sort((a, b) => b.date.getTime() - a.date.getTime());
|
|
|
|
assert.strictEqual(sorted[0].timestamp, '20250110_143022'); // Newest
|
|
assert.strictEqual(sorted[1].timestamp, '20250109_120000');
|
|
assert.strictEqual(sorted[2].timestamp, '20250108_100000'); // Oldest
|
|
});
|
|
|
|
it('should identify latest backup correctly', function () {
|
|
const backups = [
|
|
{ timestamp: '20250110_143022', date: new Date(2025, 0, 10, 14, 30, 22) },
|
|
{ timestamp: '20250109_120000', date: new Date(2025, 0, 9, 12, 0, 0) },
|
|
];
|
|
|
|
// After sorting, index 0 is latest
|
|
assert.strictEqual(backups[0].timestamp, '20250110_143022');
|
|
});
|
|
});
|
|
|
|
describe('Security Checks', function () {
|
|
it('should detect symlink in isSymlink helper', function () {
|
|
const mockFs = new MockFs();
|
|
mockFs.addSymlink('/test/symlink.json');
|
|
mockFs.addFile('/test/regular.json', '{}');
|
|
|
|
const symlinkStats = mockFs.lstatSync('/test/symlink.json');
|
|
const regularStats = mockFs.lstatSync('/test/regular.json');
|
|
|
|
assert.strictEqual(symlinkStats.isSymbolicLink(), true);
|
|
assert.strictEqual(regularStats.isSymbolicLink(), false);
|
|
});
|
|
|
|
it('should return false for non-existent files in symlink check', function () {
|
|
const mockFs = new MockFs();
|
|
|
|
// Our isSymlink implementation catches ENOENT and returns false
|
|
let isSymlink = false;
|
|
try {
|
|
mockFs.lstatSync('/nonexistent');
|
|
isSymlink = false;
|
|
} catch {
|
|
isSymlink = false;
|
|
}
|
|
|
|
assert.strictEqual(isSymlink, false);
|
|
});
|
|
});
|
|
|
|
describe('JSON Validation', function () {
|
|
it('should accept valid settings JSON object', function () {
|
|
const content = '{"env": {"ANTHROPIC_MODEL": "test"}}';
|
|
const parsed = JSON.parse(content);
|
|
|
|
const isValid = typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed);
|
|
assert.strictEqual(isValid, true);
|
|
});
|
|
|
|
it('should reject arrays as settings', function () {
|
|
const content = '["item1", "item2"]';
|
|
const parsed = JSON.parse(content);
|
|
|
|
const isValid = typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed);
|
|
assert.strictEqual(isValid, false);
|
|
});
|
|
|
|
it('should reject null as settings', function () {
|
|
const content = 'null';
|
|
const parsed = JSON.parse(content);
|
|
|
|
const isValid = typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed);
|
|
assert.strictEqual(isValid, false);
|
|
});
|
|
|
|
it('should reject primitives as settings', function () {
|
|
const primitives = ['"string"', '123', 'true', 'false'];
|
|
|
|
for (const content of primitives) {
|
|
const parsed = JSON.parse(content);
|
|
const isValid = typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed);
|
|
assert.strictEqual(isValid, false, `Should reject: ${content}`);
|
|
}
|
|
});
|
|
|
|
it('should throw on invalid JSON', function () {
|
|
const invalidJson = '{invalid json}';
|
|
|
|
assert.throws(() => JSON.parse(invalidJson), SyntaxError);
|
|
});
|
|
});
|
|
|
|
describe('RestoreMutex Pattern', function () {
|
|
/**
|
|
* Simplified RestoreMutex implementation for testing
|
|
*/
|
|
class RestoreMutex {
|
|
constructor() {
|
|
this.locked = false;
|
|
}
|
|
|
|
async acquire() {
|
|
if (this.locked) {
|
|
return false;
|
|
}
|
|
this.locked = true;
|
|
return true;
|
|
}
|
|
|
|
release() {
|
|
this.locked = false;
|
|
}
|
|
}
|
|
|
|
it('should acquire mutex when unlocked', async function () {
|
|
const mutex = new RestoreMutex();
|
|
const acquired = await mutex.acquire();
|
|
|
|
assert.strictEqual(acquired, true);
|
|
assert.strictEqual(mutex.locked, true);
|
|
});
|
|
|
|
it('should reject concurrent requests while locked', async function () {
|
|
const mutex = new RestoreMutex();
|
|
|
|
// First acquire succeeds
|
|
const first = await mutex.acquire();
|
|
assert.strictEqual(first, true);
|
|
|
|
// Second acquire fails immediately
|
|
const second = await mutex.acquire();
|
|
assert.strictEqual(second, false);
|
|
});
|
|
|
|
it('should unlock after release with no queue', async function () {
|
|
const mutex = new RestoreMutex();
|
|
|
|
await mutex.acquire();
|
|
assert.strictEqual(mutex.locked, true);
|
|
|
|
mutex.release();
|
|
assert.strictEqual(mutex.locked, false);
|
|
});
|
|
|
|
it('should allow new acquire after release', async function () {
|
|
const mutex = new RestoreMutex();
|
|
assert.strictEqual(await mutex.acquire(), true);
|
|
assert.strictEqual(await mutex.acquire(), false);
|
|
mutex.release();
|
|
assert.strictEqual(await mutex.acquire(), true);
|
|
});
|
|
});
|
|
|
|
describe('Rate Limiting Logic', function () {
|
|
it('should limit requests within time window', function () {
|
|
// Simulate rate limit check
|
|
const requests = [];
|
|
const windowMs = 60 * 1000; // 1 minute
|
|
const maxRequests = 5;
|
|
|
|
const now = Date.now();
|
|
|
|
function checkRateLimit() {
|
|
// Clean old requests
|
|
const cutoff = now - windowMs;
|
|
while (requests.length > 0 && requests[0] < cutoff) {
|
|
requests.shift();
|
|
}
|
|
|
|
if (requests.length >= maxRequests) {
|
|
return false; // Rate limited
|
|
}
|
|
|
|
requests.push(now);
|
|
return true; // Allowed
|
|
}
|
|
|
|
// First 5 requests should pass
|
|
for (let i = 0; i < 5; i++) {
|
|
assert.strictEqual(checkRateLimit(), true, `Request ${i + 1} should pass`);
|
|
}
|
|
|
|
// 6th request should be rate limited
|
|
assert.strictEqual(checkRateLimit(), false, 'Request 6 should be rate limited');
|
|
});
|
|
});
|
|
|
|
describe('API Response Format', function () {
|
|
it('should format backup list response correctly', function () {
|
|
const backups = [
|
|
{ timestamp: '20250110_143022', date: new Date('2025-01-10T14:30:22Z') },
|
|
{ timestamp: '20250109_120000', date: new Date('2025-01-09T12:00:00Z') },
|
|
];
|
|
|
|
const response = {
|
|
backups: backups.map((b, i) => ({
|
|
timestamp: b.timestamp,
|
|
date: b.date.toISOString(),
|
|
isLatest: i === 0,
|
|
})),
|
|
};
|
|
|
|
assert.strictEqual(response.backups.length, 2);
|
|
assert.strictEqual(response.backups[0].isLatest, true);
|
|
assert.strictEqual(response.backups[1].isLatest, false);
|
|
assert.strictEqual(response.backups[0].timestamp, '20250110_143022');
|
|
});
|
|
|
|
it('should format restore success response correctly', function () {
|
|
const backup = {
|
|
timestamp: '20250110_143022',
|
|
date: new Date('2025-01-10T14:30:22Z'),
|
|
};
|
|
|
|
const response = {
|
|
success: true,
|
|
timestamp: backup.timestamp,
|
|
date: backup.date.toISOString(),
|
|
};
|
|
|
|
assert.strictEqual(response.success, true);
|
|
assert.strictEqual(response.timestamp, '20250110_143022');
|
|
assert.ok(response.date.includes('2025-01-10'));
|
|
});
|
|
|
|
it('should format error response correctly', function () {
|
|
const errorResponse = { error: 'Backup not found: 20250101_000000' };
|
|
|
|
assert.ok(errorResponse.error);
|
|
assert.ok(errorResponse.error.includes('Backup not found'));
|
|
});
|
|
});
|
|
|
|
describe('Edge Cases', function () {
|
|
it('should handle empty backup directory', function () {
|
|
const mockFs = new MockFs();
|
|
mockFs.addDir('/home/user/.claude');
|
|
|
|
const files = mockFs.readdirSync('/home/user/.claude');
|
|
const backupPattern = /^settings\.json\.backup\.(\d{8}_\d{6})$/;
|
|
const backups = files.filter((f) => backupPattern.test(f));
|
|
|
|
assert.strictEqual(backups.length, 0);
|
|
});
|
|
|
|
it('should filter out non-backup files', function () {
|
|
const files = [
|
|
'settings.json',
|
|
'settings.json.backup.20250110_143022',
|
|
'settings.json.bak',
|
|
'random.txt',
|
|
'settings.json.backup.invalid',
|
|
];
|
|
|
|
const backupPattern = /^settings\.json\.backup\.(\d{8}_\d{6})$/;
|
|
const backups = files.filter((f) => backupPattern.test(f));
|
|
|
|
assert.strictEqual(backups.length, 1);
|
|
assert.strictEqual(backups[0], 'settings.json.backup.20250110_143022');
|
|
});
|
|
|
|
it('should handle missing .claude directory', function () {
|
|
const mockFs = new MockFs();
|
|
|
|
const claudeDir = '/home/user/.claude';
|
|
const exists = mockFs.existsSync(claudeDir);
|
|
|
|
assert.strictEqual(exists, false);
|
|
});
|
|
});
|
|
});
|