mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 04:18:05 +00:00
461 lines
14 KiB
TypeScript
461 lines
14 KiB
TypeScript
/**
|
|
* Unit tests for Cursor authentication module
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
import type { CursorCredentials } from '../../../src/cursor/types';
|
|
import {
|
|
validateToken,
|
|
extractUserInfo,
|
|
saveCredentials,
|
|
loadCredentials,
|
|
checkAuthStatus,
|
|
deleteCredentials,
|
|
autoDetectTokens,
|
|
} from '../../../src/cursor/cursor-auth';
|
|
|
|
// Test isolation
|
|
let originalCcsHome: string | undefined;
|
|
let tempDir: string;
|
|
|
|
beforeEach(() => {
|
|
// Save original CCS_HOME
|
|
originalCcsHome = process.env.CCS_HOME;
|
|
|
|
// Create temp directory for test isolation
|
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cursor-test-'));
|
|
process.env.CCS_HOME = tempDir;
|
|
});
|
|
|
|
afterEach(() => {
|
|
// Restore original CCS_HOME
|
|
if (originalCcsHome !== undefined) {
|
|
process.env.CCS_HOME = originalCcsHome;
|
|
} else {
|
|
delete process.env.CCS_HOME;
|
|
}
|
|
|
|
// Clean up temp directory
|
|
if (fs.existsSync(tempDir)) {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
describe('validateToken', () => {
|
|
it('should accept valid token and machineId', () => {
|
|
const token = 'a'.repeat(50); // 50 chars minimum
|
|
const machineId = 'a'.repeat(32); // 32 hex chars
|
|
expect(validateToken(token, machineId)).toBe(true);
|
|
});
|
|
|
|
it('should reject 49-char token (too short)', () => {
|
|
const token = 'a'.repeat(49); // Just under minimum
|
|
const machineId = 'a'.repeat(32);
|
|
expect(validateToken(token, machineId)).toBe(false);
|
|
});
|
|
|
|
it('should reject 31-char hex UUID (too short)', () => {
|
|
const token = 'a'.repeat(50);
|
|
const machineId = 'a'.repeat(31); // Just under 32
|
|
expect(validateToken(token, machineId)).toBe(false);
|
|
});
|
|
|
|
it('should accept UUID with hyphens (strips them)', () => {
|
|
const token = 'a'.repeat(50);
|
|
const machineId = '12345678-1234-1234-1234-123456789abc'; // 36 chars with hyphens
|
|
expect(validateToken(token, machineId)).toBe(true);
|
|
});
|
|
|
|
it('should reject empty token', () => {
|
|
const machineId = 'a'.repeat(32);
|
|
expect(validateToken('', machineId)).toBe(false);
|
|
});
|
|
|
|
it('should reject empty machineId', () => {
|
|
const token = 'a'.repeat(50);
|
|
expect(validateToken(token, '')).toBe(false);
|
|
});
|
|
|
|
it('should reject non-hex characters in machineId', () => {
|
|
const token = 'a'.repeat(50);
|
|
const machineId = 'g'.repeat(32); // 'g' is not valid hex
|
|
expect(validateToken(token, machineId)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('extractUserInfo', () => {
|
|
it('should extract email and sub from valid JWT', () => {
|
|
// JWT: {"email":"user@example.com","sub":"12345","exp":1234567890}
|
|
const payload = Buffer.from(
|
|
JSON.stringify({ email: 'user@example.com', sub: '12345', exp: 1234567890 })
|
|
).toString('base64');
|
|
const token = `header.${payload}.signature`;
|
|
|
|
const result = extractUserInfo(token);
|
|
expect(result).toEqual({
|
|
email: 'user@example.com',
|
|
userId: '12345',
|
|
exp: 1234567890,
|
|
});
|
|
});
|
|
|
|
it('should return undefined email when only sub claim exists', () => {
|
|
// JWT: {"sub":"uuid-12345","exp":1234567890}
|
|
const payload = Buffer.from(JSON.stringify({ sub: 'uuid-12345', exp: 1234567890 })).toString(
|
|
'base64'
|
|
);
|
|
const token = `header.${payload}.signature`;
|
|
|
|
const result = extractUserInfo(token);
|
|
expect(result).toEqual({
|
|
email: undefined,
|
|
userId: 'uuid-12345',
|
|
exp: 1234567890,
|
|
});
|
|
});
|
|
|
|
it('should return null for non-JWT token', () => {
|
|
const token = 'a'.repeat(50); // Plain token
|
|
const result = extractUserInfo(token);
|
|
expect(result).toBe(null);
|
|
});
|
|
|
|
it('should return null for malformed base64', () => {
|
|
const token = 'header.!!!invalid-base64!!!.signature';
|
|
const result = extractUserInfo(token);
|
|
expect(result).toBe(null);
|
|
});
|
|
|
|
it('should handle JWT with user_id instead of sub', () => {
|
|
// JWT: {"email":"user@example.com","user_id":"67890"}
|
|
const payload = Buffer.from(
|
|
JSON.stringify({ email: 'user@example.com', user_id: '67890' })
|
|
).toString('base64');
|
|
const token = `header.${payload}.signature`;
|
|
|
|
const result = extractUserInfo(token);
|
|
expect(result).toEqual({
|
|
email: 'user@example.com',
|
|
userId: '67890',
|
|
exp: undefined,
|
|
});
|
|
});
|
|
|
|
it('should return null for JWT with no meaningful claims', () => {
|
|
// JWT: {"iat":1234567890} (only issued-at, no email/sub/exp)
|
|
const payload = Buffer.from(JSON.stringify({ iat: 1234567890 })).toString('base64');
|
|
const token = `header.${payload}.signature`;
|
|
|
|
const result = extractUserInfo(token);
|
|
expect(result).toBe(null);
|
|
});
|
|
});
|
|
|
|
describe('saveCredentials and loadCredentials', () => {
|
|
it('should save and load credentials successfully', () => {
|
|
const credentials: CursorCredentials = {
|
|
accessToken: 'a'.repeat(50),
|
|
machineId: 'b'.repeat(32),
|
|
authMethod: 'auto-detect',
|
|
importedAt: new Date().toISOString(),
|
|
};
|
|
|
|
saveCredentials(credentials);
|
|
const loaded = loadCredentials();
|
|
|
|
expect(loaded).toEqual(credentials);
|
|
});
|
|
|
|
it('should return null when no credentials file exists', () => {
|
|
const loaded = loadCredentials();
|
|
expect(loaded).toBe(null);
|
|
});
|
|
|
|
it('should create directory with restrictive permissions', () => {
|
|
const credentials: CursorCredentials = {
|
|
accessToken: 'a'.repeat(50),
|
|
machineId: 'b'.repeat(32),
|
|
authMethod: 'manual',
|
|
importedAt: new Date().toISOString(),
|
|
};
|
|
|
|
saveCredentials(credentials);
|
|
|
|
// CCS_HOME is set to tempDir, but getCcsDir() appends '.ccs' to it
|
|
const credDir = path.join(tempDir, '.ccs', 'cursor');
|
|
expect(fs.existsSync(credDir)).toBe(true);
|
|
|
|
// Check directory permissions (skip on Windows)
|
|
if (process.platform !== 'win32') {
|
|
const stats = fs.statSync(credDir);
|
|
const mode = stats.mode & 0o777;
|
|
expect(mode).toBe(0o700);
|
|
}
|
|
});
|
|
|
|
it('should return null for invalid JSON in credentials file', () => {
|
|
// CCS_HOME is set to tempDir, getCcsDir() returns path.join(tempDir, '.ccs')
|
|
const credDir = path.join(tempDir, '.ccs', 'cursor');
|
|
const credPath = path.join(credDir, 'credentials.json');
|
|
fs.mkdirSync(credDir, { recursive: true });
|
|
fs.writeFileSync(credPath, 'invalid json{{{');
|
|
|
|
const loaded = loadCredentials();
|
|
expect(loaded).toBe(null);
|
|
});
|
|
|
|
it('should return null for credentials missing required fields', () => {
|
|
const credDir = path.join(tempDir, '.ccs', 'cursor');
|
|
const credPath = path.join(credDir, 'credentials.json');
|
|
fs.mkdirSync(credDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
credPath,
|
|
JSON.stringify({
|
|
accessToken: 'token',
|
|
// Missing machineId, authMethod, importedAt
|
|
})
|
|
);
|
|
|
|
const loaded = loadCredentials();
|
|
expect(loaded).toBe(null);
|
|
});
|
|
|
|
it('should return null for credentials with wrong types', () => {
|
|
const credDir = path.join(tempDir, '.ccs', 'cursor');
|
|
const credPath = path.join(credDir, 'credentials.json');
|
|
fs.mkdirSync(credDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
credPath,
|
|
JSON.stringify({
|
|
accessToken: 123, // Wrong type (number instead of string)
|
|
machineId: 'abc',
|
|
authMethod: 'auto-detect',
|
|
importedAt: new Date().toISOString(),
|
|
})
|
|
);
|
|
|
|
const loaded = loadCredentials();
|
|
expect(loaded).toBe(null);
|
|
});
|
|
|
|
it('should return null for invalid authMethod', () => {
|
|
const credDir = path.join(tempDir, '.ccs', 'cursor');
|
|
const credPath = path.join(credDir, 'credentials.json');
|
|
fs.mkdirSync(credDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
credPath,
|
|
JSON.stringify({
|
|
accessToken: 'token',
|
|
machineId: 'abc',
|
|
authMethod: 'invalid-method', // Invalid authMethod
|
|
importedAt: new Date().toISOString(),
|
|
})
|
|
);
|
|
|
|
const loaded = loadCredentials();
|
|
expect(loaded).toBe(null);
|
|
});
|
|
});
|
|
|
|
describe('checkAuthStatus', () => {
|
|
it('should return not authenticated when no credentials exist', () => {
|
|
const status = checkAuthStatus();
|
|
expect(status.authenticated).toBe(false);
|
|
expect(status.credentials).toBeUndefined();
|
|
});
|
|
|
|
it('should return authenticated for valid credentials', () => {
|
|
const credentials: CursorCredentials = {
|
|
accessToken: 'a'.repeat(50),
|
|
machineId: 'b'.repeat(32),
|
|
authMethod: 'auto-detect',
|
|
importedAt: new Date().toISOString(),
|
|
};
|
|
|
|
saveCredentials(credentials);
|
|
const status = checkAuthStatus();
|
|
|
|
expect(status.authenticated).toBe(true);
|
|
expect(status.credentials).toEqual(credentials);
|
|
expect(status.expired).toBe(false);
|
|
expect(status.tokenAge).toBeDefined();
|
|
expect(status.tokenAge).toBeLessThan(1); // Just imported
|
|
});
|
|
|
|
it('should detect expired credentials (importedAt > 24h ago)', () => {
|
|
// Create credentials from 25 hours ago
|
|
const past = new Date();
|
|
past.setHours(past.getHours() - 25);
|
|
|
|
const credentials: CursorCredentials = {
|
|
accessToken: 'a'.repeat(50),
|
|
machineId: 'b'.repeat(32),
|
|
authMethod: 'manual',
|
|
importedAt: past.toISOString(),
|
|
};
|
|
|
|
saveCredentials(credentials);
|
|
const status = checkAuthStatus();
|
|
|
|
expect(status.authenticated).toBe(true);
|
|
expect(status.expired).toBe(true);
|
|
expect(status.tokenAge).toBeGreaterThanOrEqual(24);
|
|
});
|
|
|
|
it('should return not authenticated for invalid token format', () => {
|
|
const credentials: CursorCredentials = {
|
|
accessToken: 'short', // Invalid (too short)
|
|
machineId: 'b'.repeat(32),
|
|
authMethod: 'manual',
|
|
importedAt: new Date().toISOString(),
|
|
};
|
|
|
|
saveCredentials(credentials);
|
|
const status = checkAuthStatus();
|
|
|
|
expect(status.authenticated).toBe(false);
|
|
});
|
|
|
|
it('should use JWT exp claim when available', () => {
|
|
// Create JWT token that expired 1 hour ago
|
|
const expiredTime = Math.floor(Date.now() / 1000) - 3600;
|
|
const payload = Buffer.from(
|
|
JSON.stringify({ email: 'test@example.com', sub: '123', exp: expiredTime })
|
|
).toString('base64');
|
|
const jwtToken = `header.${payload}.signature`;
|
|
|
|
const credentials: CursorCredentials = {
|
|
accessToken: jwtToken,
|
|
machineId: 'b'.repeat(32),
|
|
authMethod: 'auto-detect',
|
|
importedAt: new Date().toISOString(), // Recent import
|
|
};
|
|
|
|
saveCredentials(credentials);
|
|
const status = checkAuthStatus();
|
|
|
|
expect(status.authenticated).toBe(true);
|
|
expect(status.expired).toBe(true); // Should detect expiry from JWT exp
|
|
});
|
|
|
|
it('should handle invalid importedAt date gracefully', () => {
|
|
// Create credentials with valid format but garbage date value
|
|
const credentials: CursorCredentials = {
|
|
accessToken: 'a'.repeat(50),
|
|
machineId: 'b'.repeat(32),
|
|
authMethod: 'manual',
|
|
importedAt: 'invalid-date-garbage-2026-99-99T99:99:99Z',
|
|
};
|
|
|
|
saveCredentials(credentials);
|
|
const status = checkAuthStatus();
|
|
|
|
// Should still authenticate if token format is valid
|
|
expect(status.authenticated).toBe(true);
|
|
// tokenAge should be undefined due to invalid date (NaN from getTime())
|
|
expect(status.tokenAge).toBeUndefined();
|
|
// expired should be false (defaults to false when date parsing fails)
|
|
expect(status.expired).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('deleteCredentials', () => {
|
|
it('should delete existing credentials file and return true', () => {
|
|
const credentials: CursorCredentials = {
|
|
accessToken: 'a'.repeat(50),
|
|
machineId: 'b'.repeat(32),
|
|
authMethod: 'auto-detect',
|
|
importedAt: new Date().toISOString(),
|
|
};
|
|
|
|
saveCredentials(credentials);
|
|
expect(loadCredentials()).not.toBe(null);
|
|
|
|
const result = deleteCredentials();
|
|
expect(result).toBe(true);
|
|
expect(loadCredentials()).toBe(null);
|
|
});
|
|
|
|
it('should return false when credentials file does not exist', () => {
|
|
const result = deleteCredentials();
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('should handle multiple delete calls gracefully', () => {
|
|
const credentials: CursorCredentials = {
|
|
accessToken: 'a'.repeat(50),
|
|
machineId: 'b'.repeat(32),
|
|
authMethod: 'manual',
|
|
importedAt: new Date().toISOString(),
|
|
};
|
|
|
|
saveCredentials(credentials);
|
|
|
|
// First delete should succeed
|
|
expect(deleteCredentials()).toBe(true);
|
|
|
|
// Second delete should return false (already deleted)
|
|
expect(deleteCredentials()).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('autoDetectTokens', () => {
|
|
it('should return not found for Windows platform', () => {
|
|
// Save original platform
|
|
const originalPlatform = process.platform;
|
|
|
|
// Mock Windows platform
|
|
Object.defineProperty(process, 'platform', {
|
|
value: 'win32',
|
|
configurable: true,
|
|
});
|
|
|
|
const result = autoDetectTokens();
|
|
|
|
expect(result.found).toBe(false);
|
|
expect(result.error).toContain('not supported on Windows');
|
|
|
|
// Restore original platform
|
|
Object.defineProperty(process, 'platform', {
|
|
value: originalPlatform,
|
|
configurable: true,
|
|
});
|
|
});
|
|
|
|
it('should return not found when database file does not exist', () => {
|
|
// Skip on Windows (already covered by previous test)
|
|
if (process.platform === 'win32') {
|
|
return;
|
|
}
|
|
|
|
const originalHome = process.env.HOME;
|
|
const isolatedHome = path.join(tempDir, 'no-cursor-home');
|
|
process.env.HOME = isolatedHome;
|
|
|
|
try {
|
|
const result = autoDetectTokens();
|
|
|
|
// Should fail because isolated test home has no Cursor database
|
|
expect(result.found).toBe(false);
|
|
expect(result.error).toBeDefined();
|
|
} finally {
|
|
if (originalHome !== undefined) {
|
|
process.env.HOME = originalHome;
|
|
} else {
|
|
delete process.env.HOME;
|
|
}
|
|
}
|
|
});
|
|
|
|
it('should have found property in return type', () => {
|
|
const result = autoDetectTokens();
|
|
|
|
// Verify return type structure
|
|
expect(result).toHaveProperty('found');
|
|
expect(typeof result.found).toBe('boolean');
|
|
});
|
|
});
|