|
22 | 22 | */ |
23 | 23 |
|
24 | 24 | import { describe, it, expect } from 'vitest'; |
| 25 | +import { execFileSync } from 'child_process'; |
25 | 26 | import * as fs from 'fs'; |
26 | 27 | import * as path from 'path'; |
27 | 28 | import type { Command } from 'commander'; |
28 | 29 | import { getToolModules } from '../src/mcp/tools/registry.js'; |
| 30 | +import { isReadOnlySql } from '../src/mcp/tools/sql.js'; |
29 | 31 |
|
30 | 32 | /** |
31 | 33 | * Identifiers known to be intentionally one-sided — each entry |
@@ -275,3 +277,101 @@ describe('CLI ↔ MCP surface alignment', () => { |
275 | 277 | ).toEqual([]); |
276 | 278 | }); |
277 | 279 | }); |
| 280 | + |
| 281 | +/** |
| 282 | + * Read-only gate on `codegraph_sql` — a value-setting PRAGMA |
| 283 | + * (`PRAGMA user_version = 5`) is a write and must be rejected, while |
| 284 | + * bare introspection PRAGMAs stay allowed. The original gate matched |
| 285 | + * only the pragma name against the allowlist and ignored the trailing |
| 286 | + * `= value` assignment, so allowlisted value-form pragmas |
| 287 | + * (`user_version` / `schema_version` / `page_size`) were writable. |
| 288 | + */ |
| 289 | +describe('codegraph_sql read-only gate — value-PRAGMA rejection', () => { |
| 290 | + it('rejects value-setting PRAGMAs even when the pragma name is allowlisted', () => { |
| 291 | + expect(isReadOnlySql('PRAGMA user_version = 5')).toBe(false); |
| 292 | + expect(isReadOnlySql('PRAGMA user_version=5')).toBe(false); |
| 293 | + expect(isReadOnlySql(' pragma schema_version = 9 ')).toBe(false); |
| 294 | + expect(isReadOnlySql('PRAGMA page_size = 4096')).toBe(false); |
| 295 | + expect(isReadOnlySql('PRAGMA user_version = 5;')).toBe(false); |
| 296 | + }); |
| 297 | + |
| 298 | + it('still allows bare introspection PRAGMAs and single quoted/identifier args', () => { |
| 299 | + expect(isReadOnlySql('PRAGMA user_version')).toBe(true); |
| 300 | + expect(isReadOnlySql('PRAGMA user_version;')).toBe(true); |
| 301 | + expect(isReadOnlySql('PRAGMA table_info(nodes)')).toBe(true); |
| 302 | + expect(isReadOnlySql("PRAGMA table_info('nodes')")).toBe(true); |
| 303 | + expect(isReadOnlySql('PRAGMA integrity_check(20)')).toBe(true); |
| 304 | + }); |
| 305 | + |
| 306 | + it('still rejects non-allowlisted pragma names and plain writes', () => { |
| 307 | + expect(isReadOnlySql('PRAGMA cache_size = 99999')).toBe(false); |
| 308 | + expect(isReadOnlySql('PRAGMA journal_mode = WAL')).toBe(false); |
| 309 | + expect(isReadOnlySql('DELETE FROM nodes')).toBe(false); |
| 310 | + expect(isReadOnlySql('SELECT * FROM nodes')).toBe(true); |
| 311 | + }); |
| 312 | +}); |
| 313 | + |
| 314 | +/** |
| 315 | + * CLI behaviour parity — spawns the real CLI (`tsx src/bin/codegraph.ts`) |
| 316 | + * against this repo's own index. These guard four audited |
| 317 | + * CLI-vs-MCP divergences: |
| 318 | + * - `sql` exits non-zero on a rejected / invalid query (scripts can |
| 319 | + * detect failure). |
| 320 | + * - `find --by name` exact mode returns the container + members set |
| 321 | + * the MCP tool returns, not a fuzzy relevance rank. |
| 322 | + * - `coverage <symbol>` positional selects symbol mode. |
| 323 | + * - `role` with no args produces the project-wide distribution table. |
| 324 | + */ |
| 325 | +describe('CLI behaviour parity (spawned)', () => { |
| 326 | + const repoRoot = path.join(__dirname, '..'); |
| 327 | + const cliEntry = path.join(repoRoot, 'src', 'bin', 'codegraph.ts'); |
| 328 | + const indexed = fs.existsSync(path.join(repoRoot, '.codegraph')); |
| 329 | + |
| 330 | + /** Run the CLI, returning stdout+stderr and the exit code. */ |
| 331 | + function runCli(cliArgs: string[]): { out: string; code: number } { |
| 332 | + try { |
| 333 | + const out = execFileSync('npx', ['tsx', cliEntry, ...cliArgs], { |
| 334 | + cwd: repoRoot, |
| 335 | + encoding: 'utf-8', |
| 336 | + stdio: ['ignore', 'pipe', 'pipe'], |
| 337 | + }); |
| 338 | + return { out, code: 0 }; |
| 339 | + } catch (err) { |
| 340 | + const e = err as { status?: number; stdout?: string; stderr?: string }; |
| 341 | + return { out: (e.stdout ?? '') + (e.stderr ?? ''), code: e.status ?? 1 }; |
| 342 | + } |
| 343 | + } |
| 344 | + |
| 345 | + it.skipIf(!indexed)('sql exits non-zero on a rejected write query', () => { |
| 346 | + const { code } = runCli(['sql', 'DELETE FROM nodes']); |
| 347 | + expect(code).not.toBe(0); |
| 348 | + }, 60_000); |
| 349 | + |
| 350 | + it.skipIf(!indexed)('sql exits non-zero on an invalid (no such table) query', () => { |
| 351 | + const { code } = runCli(['sql', 'SELECT * FROM no_such_table_xyz']); |
| 352 | + expect(code).not.toBe(0); |
| 353 | + }, 60_000); |
| 354 | + |
| 355 | + it.skipIf(!indexed)('find --by name exact returns the container + its members', () => { |
| 356 | + const { out, code } = runCli(['find', '--by', 'name', 'GraphTraverser']); |
| 357 | + expect(code).toBe(0); |
| 358 | + // MCP exact mode lists the class then its member methods, not a |
| 359 | + // fuzzy relevance rank with `(NN%)` scores and unrelated imports. |
| 360 | + expect(out).toContain('GraphTraverser (class)'); |
| 361 | + expect(out).toContain('traverseBFS (method)'); |
| 362 | + expect(out).not.toMatch(/\(\d+%\)/); |
| 363 | + }, 60_000); |
| 364 | + |
| 365 | + it.skipIf(!indexed)('coverage with a bare positional symbol selects symbol mode', () => { |
| 366 | + const { out, code } = runCli(['coverage', 'computeMetrics']); |
| 367 | + expect(code).toBe(0); |
| 368 | + expect(out).toContain('Coverage for `computeMetrics`'); |
| 369 | + expect(out).not.toContain('lowest first'); |
| 370 | + }, 60_000); |
| 371 | + |
| 372 | + it.skipIf(!indexed)('role with no args produces the project-wide distribution table', () => { |
| 373 | + const { out, code } = runCli(['role']); |
| 374 | + expect(code).toBe(0); |
| 375 | + expect(out).toContain('Role distribution (project-wide)'); |
| 376 | + }, 60_000); |
| 377 | +}); |
0 commit comments