mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 14:16:43 +00:00
537 lines
18 KiB
TypeScript
537 lines
18 KiB
TypeScript
/**
|
|
* Cursor Routes Tests
|
|
* Endpoint contract tests without module-level mocks.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'bun:test';
|
|
import express from 'express';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import type { Server } from 'http';
|
|
import { execFileSync, spawnSync } from 'child_process';
|
|
|
|
let server: Server;
|
|
let baseUrl = '';
|
|
let tempDir = '';
|
|
let originalCcsHome: string | undefined;
|
|
|
|
let setGlobalConfigDir: (dir: string | undefined) => void;
|
|
let getCcsDir: () => string;
|
|
let loadOrCreateUnifiedConfig: () => {
|
|
cursor?: {
|
|
enabled?: boolean;
|
|
port?: number;
|
|
auto_start?: boolean;
|
|
ghost_mode?: boolean;
|
|
model?: string;
|
|
};
|
|
};
|
|
let saveUnifiedConfig: (config: {
|
|
cursor?: {
|
|
enabled?: boolean;
|
|
port?: number;
|
|
auto_start?: boolean;
|
|
ghost_mode?: boolean;
|
|
model?: string;
|
|
};
|
|
}) => void;
|
|
let saveCredentials: (credentials: {
|
|
accessToken: string;
|
|
machineId: string;
|
|
authMethod: 'manual' | 'auto-detect';
|
|
importedAt: string;
|
|
}) => void;
|
|
let deleteCredentials: () => boolean;
|
|
let checkAuthStatus: () => {
|
|
authenticated: boolean;
|
|
expired?: boolean;
|
|
credentials?: {
|
|
authMethod?: 'manual' | 'auto-detect';
|
|
machineId?: string;
|
|
};
|
|
};
|
|
let getTokenStoragePath: () => string;
|
|
let getDaemonStartPreconditionError: (
|
|
input: { enabled: boolean; authenticated: boolean; tokenExpired?: boolean }
|
|
) => { status: number; error: string } | null;
|
|
let getAutoDetectFailureStatus: (
|
|
reason?:
|
|
| 'db_not_found'
|
|
| 'sqlite_unavailable'
|
|
| 'db_query_failed'
|
|
| 'access_token_not_found'
|
|
| 'machine_id_not_found'
|
|
| 'invalid_token_format'
|
|
) => number;
|
|
|
|
function seedCursorConfig(overrides: {
|
|
enabled?: boolean;
|
|
port?: number;
|
|
auto_start?: boolean;
|
|
ghost_mode?: boolean;
|
|
model?: string;
|
|
} = {}): void {
|
|
const config = loadOrCreateUnifiedConfig();
|
|
config.cursor = {
|
|
enabled: overrides.enabled ?? true,
|
|
port: overrides.port ?? 20129,
|
|
auto_start: overrides.auto_start ?? false,
|
|
ghost_mode: overrides.ghost_mode ?? true,
|
|
model: overrides.model ?? 'gpt-5.3-codex',
|
|
};
|
|
saveUnifiedConfig(config);
|
|
}
|
|
|
|
function seedCredentials(expired: boolean): void {
|
|
saveCredentials({
|
|
accessToken: 'a'.repeat(60),
|
|
machineId: '1234567890abcdef1234567890abcdef',
|
|
authMethod: 'manual',
|
|
importedAt: expired
|
|
? new Date(Date.now() - 26 * 60 * 60 * 1000).toISOString()
|
|
: new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
originalCcsHome = process.env.CCS_HOME;
|
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cursor-routes-test-'));
|
|
process.env.CCS_HOME = tempDir;
|
|
|
|
const configManager = await import('../../../src/utils/config-manager');
|
|
setGlobalConfigDir = configManager.setGlobalConfigDir;
|
|
getCcsDir = configManager.getCcsDir;
|
|
setGlobalConfigDir(undefined);
|
|
|
|
const unifiedConfig = await import('../../../src/config/unified-config-loader');
|
|
loadOrCreateUnifiedConfig = unifiedConfig.loadOrCreateUnifiedConfig;
|
|
saveUnifiedConfig = unifiedConfig.saveUnifiedConfig;
|
|
|
|
const cursorAuth = await import('../../../src/cursor/cursor-auth');
|
|
saveCredentials = cursorAuth.saveCredentials;
|
|
deleteCredentials = cursorAuth.deleteCredentials;
|
|
checkAuthStatus = cursorAuth.checkAuthStatus;
|
|
getTokenStoragePath = cursorAuth.getTokenStoragePath;
|
|
|
|
const cursorRoutesModule = await import('../../../src/web-server/routes/cursor-routes');
|
|
getDaemonStartPreconditionError = cursorRoutesModule.getDaemonStartPreconditionError;
|
|
getAutoDetectFailureStatus = cursorRoutesModule.getAutoDetectFailureStatus;
|
|
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use('/api/cursor', cursorRoutesModule.default);
|
|
|
|
server = app.listen(0, '127.0.0.1');
|
|
await new Promise<void>((resolve) => server.on('listening', () => resolve()));
|
|
const address = server.address();
|
|
if (!address || typeof address === 'string') {
|
|
throw new Error('Unable to resolve test server port');
|
|
}
|
|
baseUrl = `http://127.0.0.1:${address.port}`;
|
|
});
|
|
|
|
beforeEach(() => {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
fs.mkdirSync(tempDir, { recursive: true });
|
|
|
|
setGlobalConfigDir(undefined);
|
|
const ccsDir = getCcsDir();
|
|
if (!fs.existsSync(ccsDir)) {
|
|
fs.mkdirSync(ccsDir, { recursive: true });
|
|
}
|
|
|
|
seedCursorConfig();
|
|
|
|
// Ensure clean auth state for each test.
|
|
deleteCredentials();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (server) {
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
}
|
|
|
|
if (originalCcsHome !== undefined) {
|
|
process.env.CCS_HOME = originalCcsHome;
|
|
} else {
|
|
delete process.env.CCS_HOME;
|
|
}
|
|
setGlobalConfigDir(undefined);
|
|
|
|
if (tempDir && fs.existsSync(tempDir)) {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
describe('Cursor Routes Logic', () => {
|
|
describe('POST /daemon/start preconditions', () => {
|
|
it('blocks start when integration is disabled', () => {
|
|
const result = getDaemonStartPreconditionError({
|
|
enabled: false,
|
|
authenticated: true,
|
|
tokenExpired: false,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
status: 400,
|
|
error: 'Cursor integration is disabled. Enable it before starting daemon.',
|
|
});
|
|
});
|
|
|
|
it('blocks start when not authenticated', () => {
|
|
const result = getDaemonStartPreconditionError({
|
|
enabled: true,
|
|
authenticated: false,
|
|
tokenExpired: false,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
status: 401,
|
|
error: 'Cursor authentication required. Import credentials before starting daemon.',
|
|
});
|
|
});
|
|
|
|
it('blocks start when token is expired', () => {
|
|
const result = getDaemonStartPreconditionError({
|
|
enabled: true,
|
|
authenticated: true,
|
|
tokenExpired: true,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
status: 401,
|
|
error: 'Cursor credentials expired. Re-authenticate before starting daemon.',
|
|
});
|
|
});
|
|
|
|
it('allows start when all preconditions are met', () => {
|
|
const result = getDaemonStartPreconditionError({
|
|
enabled: true,
|
|
authenticated: true,
|
|
tokenExpired: false,
|
|
});
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('HTTP contracts', () => {
|
|
it('maps invalid_token_format auto-detect failures to HTTP 400', () => {
|
|
expect(getAutoDetectFailureStatus('invalid_token_format')).toBe(400);
|
|
});
|
|
|
|
it('GET /api/cursor/status returns current state', async () => {
|
|
const res = await fetch(`${baseUrl}/api/cursor/status`);
|
|
expect(res.status).toBe(200);
|
|
|
|
const json = (await res.json()) as {
|
|
enabled: boolean;
|
|
authenticated: boolean;
|
|
token_expired: boolean;
|
|
daemon_running: boolean;
|
|
port: number;
|
|
};
|
|
|
|
expect(json.enabled).toBe(true);
|
|
expect(json.authenticated).toBe(false);
|
|
expect(json.token_expired).toBe(false);
|
|
expect(json.daemon_running).toBe(false);
|
|
expect(json.port).toBe(20129);
|
|
});
|
|
|
|
it('POST /api/cursor/auth/import validates required fields', async () => {
|
|
const res = await fetch(`${baseUrl}/api/cursor/auth/import`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ accessToken: 'only-token' }),
|
|
});
|
|
|
|
expect(res.status).toBe(400);
|
|
const json = (await res.json()) as { error?: string };
|
|
expect(json.error).toContain('Missing accessToken or machineId');
|
|
});
|
|
|
|
it('POST /api/cursor/auth/import rejects invalid token format', async () => {
|
|
const res = await fetch(`${baseUrl}/api/cursor/auth/import`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
accessToken: 'short',
|
|
machineId: 'bad',
|
|
}),
|
|
});
|
|
|
|
expect(res.status).toBe(400);
|
|
const json = (await res.json()) as { error?: string };
|
|
expect(json.error).toContain('Invalid token or machine ID format');
|
|
expect(checkAuthStatus().authenticated).toBe(false);
|
|
});
|
|
|
|
it('POST /api/cursor/auth/import persists valid credentials', async () => {
|
|
const res = await fetch(`${baseUrl}/api/cursor/auth/import`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
accessToken: 'a'.repeat(60),
|
|
machineId: '1234567890abcdef1234567890abcdef',
|
|
}),
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(checkAuthStatus().authenticated).toBe(true);
|
|
});
|
|
|
|
it('POST /api/cursor/auth/auto-detect returns 404 when no token source found', async () => {
|
|
const originalHome = process.env.HOME;
|
|
const isolatedHome = path.join(tempDir, 'auto-detect-empty-home');
|
|
process.env.HOME = isolatedHome;
|
|
fs.mkdirSync(isolatedHome, { recursive: true });
|
|
|
|
try {
|
|
const res = await fetch(`${baseUrl}/api/cursor/auth/auto-detect`, {
|
|
method: 'POST',
|
|
});
|
|
|
|
expect(res.status).toBe(404);
|
|
const json = (await res.json()) as { error?: string; reason?: string };
|
|
expect(typeof json.error).toBe('string');
|
|
expect(json.error?.length).toBeGreaterThan(0);
|
|
expect(json.reason).toBe('db_not_found');
|
|
expect(json.error).not.toContain(isolatedHome);
|
|
} finally {
|
|
if (originalHome !== undefined) {
|
|
process.env.HOME = originalHome;
|
|
} else {
|
|
delete process.env.HOME;
|
|
}
|
|
}
|
|
});
|
|
|
|
it('POST /api/cursor/auth/auto-detect persists credentials on success', async () => {
|
|
if (process.platform === 'win32') {
|
|
return;
|
|
}
|
|
|
|
const sqliteCheck = spawnSync('sqlite3', ['--version'], { stdio: 'ignore' });
|
|
if (sqliteCheck.status !== 0) {
|
|
return;
|
|
}
|
|
|
|
const originalHome = process.env.HOME;
|
|
const fakeHome = path.join(tempDir, 'fake-home');
|
|
process.env.HOME = fakeHome;
|
|
fs.mkdirSync(fakeHome, { recursive: true });
|
|
|
|
const token = 'a'.repeat(60);
|
|
const machineId = '1234567890abcdef1234567890abcdef';
|
|
|
|
try {
|
|
const dbPath = getTokenStoragePath();
|
|
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
execFileSync('sqlite3', [dbPath, 'CREATE TABLE IF NOT EXISTS itemTable (key TEXT PRIMARY KEY, value TEXT);']);
|
|
execFileSync('sqlite3', [
|
|
dbPath,
|
|
`INSERT OR REPLACE INTO itemTable (key, value) VALUES ('cursorAuth/accessToken', '${token}');`,
|
|
]);
|
|
execFileSync('sqlite3', [
|
|
dbPath,
|
|
`INSERT OR REPLACE INTO itemTable (key, value) VALUES ('storage.serviceMachineId', '${machineId}');`,
|
|
]);
|
|
|
|
const res = await fetch(`${baseUrl}/api/cursor/auth/auto-detect`, {
|
|
method: 'POST',
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
const json = (await res.json()) as { success?: boolean };
|
|
expect(json.success).toBe(true);
|
|
|
|
const auth = checkAuthStatus();
|
|
expect(auth.authenticated).toBe(true);
|
|
expect(auth.credentials?.authMethod).toBe('auto-detect');
|
|
expect(auth.credentials?.machineId).toBe(machineId);
|
|
} finally {
|
|
if (originalHome !== undefined) {
|
|
process.env.HOME = originalHome;
|
|
} else {
|
|
delete process.env.HOME;
|
|
}
|
|
}
|
|
});
|
|
|
|
it('POST /api/cursor/auth/auto-detect returns 503 when sqlite3 is unavailable', async () => {
|
|
if (process.platform === 'win32') {
|
|
return;
|
|
}
|
|
|
|
const originalHome = process.env.HOME;
|
|
const originalPath = process.env.PATH;
|
|
const originalSqliteBin = process.env.CCS_CURSOR_SQLITE_BIN;
|
|
const fakeHome = path.join(tempDir, 'sqlite-missing-home');
|
|
process.env.HOME = fakeHome;
|
|
process.env.CCS_CURSOR_SQLITE_BIN = 'definitely-missing-sqlite3';
|
|
|
|
try {
|
|
const dbPath = getTokenStoragePath();
|
|
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
fs.writeFileSync(dbPath, '');
|
|
|
|
const res = await fetch(`${baseUrl}/api/cursor/auth/auto-detect`, {
|
|
method: 'POST',
|
|
});
|
|
|
|
expect(res.status).toBe(503);
|
|
const json = (await res.json()) as { reason?: string; db_path?: string };
|
|
expect(json.reason).toBe('sqlite_unavailable');
|
|
expect(json.db_path).toBeUndefined();
|
|
} finally {
|
|
if (originalHome !== undefined) {
|
|
process.env.HOME = originalHome;
|
|
} else {
|
|
delete process.env.HOME;
|
|
}
|
|
|
|
if (originalPath !== undefined) {
|
|
process.env.PATH = originalPath;
|
|
} else {
|
|
delete process.env.PATH;
|
|
}
|
|
if (originalSqliteBin !== undefined) {
|
|
process.env.CCS_CURSOR_SQLITE_BIN = originalSqliteBin;
|
|
} else {
|
|
delete process.env.CCS_CURSOR_SQLITE_BIN;
|
|
}
|
|
}
|
|
});
|
|
|
|
it('POST /api/cursor/auth/auto-detect returns 500 when the database exists but cannot be queried', async () => {
|
|
if (process.platform === 'win32') {
|
|
return;
|
|
}
|
|
|
|
const sqliteCheck = spawnSync('sqlite3', ['--version'], { stdio: 'ignore' });
|
|
if (sqliteCheck.status !== 0) {
|
|
return;
|
|
}
|
|
|
|
const originalHome = process.env.HOME;
|
|
const fakeHome = path.join(tempDir, 'query-failed-home');
|
|
process.env.HOME = fakeHome;
|
|
|
|
try {
|
|
const dbPath = getTokenStoragePath();
|
|
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
fs.writeFileSync(dbPath, 'not-a-sqlite-database');
|
|
|
|
const res = await fetch(`${baseUrl}/api/cursor/auth/auto-detect`, {
|
|
method: 'POST',
|
|
});
|
|
|
|
expect(res.status).toBe(500);
|
|
const json = (await res.json()) as { reason?: string; db_path?: string };
|
|
expect(json.reason).toBe('db_query_failed');
|
|
expect(json.db_path).toBeUndefined();
|
|
} finally {
|
|
if (originalHome !== undefined) {
|
|
process.env.HOME = originalHome;
|
|
} else {
|
|
delete process.env.HOME;
|
|
}
|
|
}
|
|
});
|
|
|
|
it('POST /api/cursor/daemon/start returns 400 when integration is disabled', async () => {
|
|
seedCursorConfig({ enabled: false });
|
|
seedCredentials(false);
|
|
|
|
const res = await fetch(`${baseUrl}/api/cursor/daemon/start`, { method: 'POST' });
|
|
expect(res.status).toBe(400);
|
|
const json = (await res.json()) as { success?: boolean; error?: string };
|
|
expect(json.success).toBe(false);
|
|
expect(json.error).toContain('disabled');
|
|
});
|
|
|
|
it('POST /api/cursor/daemon/start returns 401 when unauthenticated', async () => {
|
|
seedCursorConfig({ enabled: true });
|
|
|
|
const res = await fetch(`${baseUrl}/api/cursor/daemon/start`, { method: 'POST' });
|
|
expect(res.status).toBe(401);
|
|
const json = (await res.json()) as { error?: string };
|
|
expect(json.error).toContain('authentication required');
|
|
});
|
|
|
|
it('POST /api/cursor/daemon/start returns 401 when token is expired', async () => {
|
|
seedCursorConfig({ enabled: true });
|
|
seedCredentials(true);
|
|
|
|
const res = await fetch(`${baseUrl}/api/cursor/daemon/start`, { method: 'POST' });
|
|
expect(res.status).toBe(401);
|
|
const json = (await res.json()) as { error?: string };
|
|
expect(json.error).toContain('expired');
|
|
});
|
|
|
|
it(
|
|
'POST /api/cursor/daemon/start starts daemon and /daemon/stop stops it',
|
|
async () => {
|
|
const port = 15000 + Math.floor(Math.random() * 20000);
|
|
seedCursorConfig({ enabled: true, port });
|
|
seedCredentials(false);
|
|
|
|
const startRes = await fetch(`${baseUrl}/api/cursor/daemon/start`, { method: 'POST' });
|
|
expect(startRes.status).toBe(200);
|
|
|
|
const startJson = (await startRes.json()) as { success?: boolean; pid?: number };
|
|
expect(startJson.success).toBe(true);
|
|
expect(typeof startJson.pid).toBe('number');
|
|
|
|
const stopRes = await fetch(`${baseUrl}/api/cursor/daemon/stop`, { method: 'POST' });
|
|
expect(stopRes.status).toBe(200);
|
|
const stopJson = (await stopRes.json()) as { success?: boolean };
|
|
expect(stopJson.success).toBe(true);
|
|
},
|
|
35000
|
|
);
|
|
|
|
it('POST /api/cursor/daemon/stop returns success when daemon is not running', async () => {
|
|
const res = await fetch(`${baseUrl}/api/cursor/daemon/stop`, { method: 'POST' });
|
|
expect(res.status).toBe(200);
|
|
const json = (await res.json()) as { success?: boolean };
|
|
expect(json.success).toBe(true);
|
|
});
|
|
|
|
it('GET /api/cursor/models returns current model and list payload', async () => {
|
|
const res = await fetch(`${baseUrl}/api/cursor/models`);
|
|
expect(res.status).toBe(200);
|
|
|
|
const json = (await res.json()) as { models: Array<{ id: string }>; current: string };
|
|
expect(Array.isArray(json.models)).toBe(true);
|
|
expect(json.models.length).toBeGreaterThan(0);
|
|
expect(json.current).toBe('gpt-5.3-codex');
|
|
});
|
|
|
|
it('POST /api/cursor/probe returns auth failure when credentials are missing', async () => {
|
|
const res = await fetch(`${baseUrl}/api/cursor/probe`, { method: 'POST' });
|
|
expect(res.status).toBe(401);
|
|
|
|
const json = (await res.json()) as { ok?: boolean; stage?: string; error_type?: string };
|
|
expect(json.ok).toBe(false);
|
|
expect(json.stage).toBe('auth');
|
|
expect(json.error_type).toBe('authentication_error');
|
|
});
|
|
|
|
it('POST /api/cursor/probe returns daemon failure when daemon is down and auto_start is false', async () => {
|
|
seedCursorConfig({ enabled: true, auto_start: false });
|
|
seedCredentials(false);
|
|
|
|
const res = await fetch(`${baseUrl}/api/cursor/probe`, { method: 'POST' });
|
|
expect(res.status).toBe(503);
|
|
|
|
const json = (await res.json()) as { ok?: boolean; stage?: string; error_type?: string };
|
|
expect(json.ok).toBe(false);
|
|
expect(json.stage).toBe('daemon');
|
|
expect(json.error_type).toBe('daemon_not_running');
|
|
});
|
|
});
|
|
});
|