Files
ccs/tests/unit/web-server/cursor-routes.test.ts
T

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