Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions packages/api/src/__tests__/graph-route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

vi.mock('@codegraph/core', () => ({
codeGraphService: {
getFullGraph: vi.fn(),
getFileSubgraph: vi.fn(),
getSymbolReferences: vi.fn(),
getDependencyTree: vi.fn(),
},
getGraphClient: vi.fn(),
}));

vi.mock('@codegraph/graph', () => ({
createQueries: vi.fn(),
}));

import { codeGraphService, getGraphClient } from '@codegraph/core';
import { createQueries } from '@codegraph/graph';
import { graphRoutes } from '../routes/graph';

const mockedFullGraph = vi.mocked(codeGraphService.getFullGraph);
const mockedReferences = vi.mocked(codeGraphService.getSymbolReferences);
const mockedDependencies = vi.mocked(codeGraphService.getDependencyTree);
const mockedGetGraphClient = vi.mocked(getGraphClient);
const mockedCreateQueries = vi.mocked(createQueries);

async function errorFor(path: string): Promise<{ status: number; error: string }> {
const response = await graphRoutes.request(path);
const body = (await response.json()) as { error: string };
return { status: response.status, error: body.error };
}

describe('graph route numeric boundaries', () => {
beforeEach(() => {
vi.clearAllMocks();
mockedFullGraph.mockResolvedValue({ nodes: [], edges: [] });
mockedReferences.mockResolvedValue({ references: [], referencingFiles: [], truncated: false });
mockedDependencies.mockResolvedValue({ nodes: [], edges: [] });
});

it.each(['NaN', 'Infinity', '0', '-1', '1.5', '1001'])(
'rejects full graph limit=%s before touching the graph',
async (limit) => {
const result = await errorFor(`/api/graph/full?limit=${limit}`);

expect(result.status).toBe(400);
expect(result.error).toBe('limit must be a positive integer between 1 and 1000');
expect(mockedFullGraph).not.toHaveBeenCalled();
},
);

it('accepts the full graph upper limit', async () => {
const response = await graphRoutes.request('/api/graph/full?limit=1000');

expect(response.status).toBe(200);
expect(mockedFullGraph).toHaveBeenCalledWith(1000, undefined);
});

it.each(['NaN', 'Infinity', '0', '-1', '1.5', '11'])(
'rejects dependency depth=%s before touching the graph',
async (depth) => {
const result = await errorFor(`/api/graph/dependencies?path=/x/main.ts&depth=${depth}`);

expect(result.status).toBe(400);
expect(result.error).toBe('depth must be a positive integer between 1 and 10');
expect(mockedDependencies).not.toHaveBeenCalled();
},
);

it.each(['NaN', 'Infinity', '0', '-1', '1.5', '1001'])(
'rejects reference limit=%s before touching the graph',
async (limit) => {
const result = await errorFor(`/api/graph/references?name=run&limit=${limit}`);

expect(result.status).toBe(400);
expect(result.error).toBe('limit must be a positive integer between 1 and 1000');
expect(mockedReferences).not.toHaveBeenCalled();
},
);

it.each(['NaN', 'Infinity', '0', '-1', '1.5', '10000001'])(
'rejects reference startLine=%s before touching the graph',
async (startLine) => {
const result = await errorFor(`/api/graph/references?name=run&startLine=${startLine}`);

expect(result.status).toBe(400);
expect(result.error).toBe('startLine must be a positive integer between 1 and 10000000');
expect(mockedReferences).not.toHaveBeenCalled();
},
);
});

describe('GET /api/graph/file-relationships', () => {
const relationshipResult = {
filePath: '/x/main.ts',
containedSymbols: [{ id: 'Function:/x/main.ts:run:5', label: 'Function', displayName: 'run', filePath: '/x/main.ts', data: {} }],
imports: [{ id: 'File:/x/dep.ts', label: 'File', displayName: 'dep.ts', filePath: '/x/dep.ts', data: {} }],
importers: [{ id: 'File:/x/importer.ts', label: 'File', displayName: 'importer.ts', filePath: '/x/importer.ts', data: {} }],
knowledgeEntities: [{ id: 'Entity:Decision:Main entry point', label: 'Entity', displayName: 'Main entry point', data: { text: 'Main entry point', type: 'Decision' } }],
};
const getFileRelationships = vi.fn();

beforeEach(() => {
vi.clearAllMocks();
mockedGetGraphClient.mockResolvedValue({} as never);
getFileRelationships.mockResolvedValue(relationshipResult);
mockedCreateQueries.mockReturnValue({ getFileRelationships } as never);
});

it('requires path', async () => {
const result = await errorFor('/api/graph/file-relationships');

expect(result).toEqual({ status: 400, error: 'path parameter is required' });
expect(mockedGetGraphClient).not.toHaveBeenCalled();
});

it.each(['NaN', 'Infinity', '0', '-1', '1.5', '501'])(
'rejects limit=%s before touching the graph',
async (limit) => {
const result = await errorFor(`/api/graph/file-relationships?path=/x/main.ts&limit=${limit}`);

expect(result.status).toBe(400);
expect(result.error).toBe('limit must be a positive integer between 1 and 500');
expect(mockedGetGraphClient).not.toHaveBeenCalled();
},
);

it('returns the frozen categorized response shape', async () => {
const response = await graphRoutes.request('/api/graph/file-relationships?path=/x/main.ts&limit=50');

expect(response.status).toBe(200);
expect(await response.json()).toEqual(relationshipResult);
expect(getFileRelationships).toHaveBeenCalledWith('/x/main.ts', 50);
});
});
13 changes: 12 additions & 1 deletion packages/api/src/__tests__/profile-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { profileRoutes } from '../routes/profile';
const mockedGetGraphStats = vi.mocked(codeGraphService.getGraphStats);
const mockedGetGraphClient = vi.mocked(getGraphClient);

describe('GET /api/profile: projectPath validation', () => {
describe('GET /api/profile: boundary validation', () => {
beforeEach(() => {
vi.clearAllMocks();
});
Expand Down Expand Up @@ -53,4 +53,15 @@ describe('GET /api/profile: projectPath validation', () => {
const res = await profileRoutes.request('/api/profile?projectPath=/abs/path');
expect(res.status).toBe(200);
});

it.each(['0', '-1', '1.5', 'Infinity', '1001'])(
'returns 400 for invalid limit %s and never touches the graph',
async (limit) => {
const res = await profileRoutes.request(`/api/profile?limit=${encodeURIComponent(limit)}`);
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toBe('limit must be an integer between 1 and 1000');
expect(mockedGetGraphClient).not.toHaveBeenCalled();
},
);
});
20 changes: 20 additions & 0 deletions packages/api/src/__tests__/search-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,26 @@ async function searchJson(query: string): Promise<{ status: number; body: Record
return { status: res.status, body };
}

describe('GET /api/search: limit validation', () => {
beforeEach(() => {
mockedSearch.mockReset();
mockedGetGraphClient.mockReset();
mockedGetKnownNodeLabels.mockReset();
});

it.each(['0', '-1', '1.5', '101', 'not-a-number'])(
'rejects limit=%s with 400 before calling the search service',
async (limit) => {
const { status, body } = await searchJson(`q=test&limit=${encodeURIComponent(limit)}`);

expect(status).toBe(400);
expect(body).toEqual({ error: 'limit parameter must be an integer between 1 and 100' });
expect(mockedSearch).not.toHaveBeenCalled();
expect(mockedGetGraphClient).not.toHaveBeenCalled();
},
);
});

describe('GET /api/search: types filter emptying the page', () => {
beforeEach(() => {
mockedSearch.mockReset();
Expand Down
52 changes: 52 additions & 0 deletions packages/api/src/__tests__/source-route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

vi.mock('@codegraph/core', () => ({
codeGraphService: { getProjects: vi.fn() },
}));

import { codeGraphService } from '@codegraph/core';
import { sourceRoutes } from '../routes/source';

const mockedGetProjects = vi.mocked(codeGraphService.getProjects);

describe('GET /api/source: numeric boundary validation', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it.each([
['startLine', '0', 'startLine must be an integer between 1 and 1000000'],
['startLine', '1.5', 'startLine must be an integer between 1 and 1000000'],
['endLine', '-1', 'endLine must be an integer between 0 and 1000000'],
['endLine', 'Infinity', 'endLine must be an integer between 0 and 1000000'],
['context', '-1', 'context must be an integer between 0 and 1000'],
['context', '1001', 'context must be an integer between 0 and 1000'],
])('returns 400 for invalid %s=%s before graph access', async (name, value, message) => {
const query = new URLSearchParams({
path: '/work/project/src/a.ts',
[name]: value,
});

const res = await sourceRoutes.request(`/api/source?${query.toString()}`);

expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toBe(message);
expect(mockedGetProjects).not.toHaveBeenCalled();
});

it('returns 400 when endLine precedes startLine', async () => {
const query = new URLSearchParams({
path: '/work/project/src/a.ts',
startLine: '20',
endLine: '10',
});

const res = await sourceRoutes.request(`/api/source?${query.toString()}`);

expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toBe('endLine must be 0 or greater than or equal to startLine');
expect(mockedGetProjects).not.toHaveBeenCalled();
});
});
82 changes: 72 additions & 10 deletions packages/api/src/routes/graph.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,39 @@
import { Hono } from 'hono';
import { codeGraphService, getGraphClient } from '@codegraph/core';
import { createQueries } from '@codegraph/graph';
import { safeErrorMessage } from '../safe-error';

export const graphRoutes = new Hono();

const FULL_GRAPH_LIMIT_MAX = 1000;
const FILE_RELATIONSHIP_LIMIT_MAX = 500;
const REFERENCE_LIMIT_MAX = 1000;
const REFERENCE_START_LINE_MAX = 10_000_000;
const DEPENDENCY_DEPTH_MAX = 10;

type BoundedIntegerResult =
| { valid: true; value?: number }
| { valid: false; error: string };

function boundedPositiveInteger(
raw: string | undefined,
name: string,
max: number,
): BoundedIntegerResult {
if (raw === undefined) return { valid: true };
const value = Number(raw);
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 1 || value > max) {
return { valid: false, error: `${name} must be a positive integer between 1 and ${max}` };
}
return { valid: true, value };
}

/** GET /api/graph/full?limit=N&projectId=X — returns { nodes, edges } optionally filtered by project */
graphRoutes.get('/api/graph/full', async (c) => {
try {
const limit = Number(c.req.query('limit') ?? 100);
const parsedLimit = boundedPositiveInteger(c.req.query('limit'), 'limit', FULL_GRAPH_LIMIT_MAX);
if (!parsedLimit.valid) return c.json({ error: parsedLimit.error }, 400);
const limit = parsedLimit.value ?? 100;
const projectId = c.req.query('projectId');

// If projectId given, resolve rootPath and filter
Expand Down Expand Up @@ -37,6 +63,38 @@ graphRoutes.get('/api/graph/full', async (c) => {
}
});

/**
* GET /api/graph/file-relationships?path=X&limit=N
*
* Returns the four relationship collections consumed by the File detail panel.
* Each collection is independently bounded to 1..500 items; the default is 100.
*/
graphRoutes.get('/api/graph/file-relationships', async (c) => {
try {
const filePath = c.req.query('path');
if (!filePath) return c.json({ error: 'path parameter is required' }, 400);

const parsedLimit = boundedPositiveInteger(
c.req.query('limit'),
'limit',
FILE_RELATIONSHIP_LIMIT_MAX,
);
if (!parsedLimit.valid) return c.json({ error: parsedLimit.error }, 400);

const client = await getGraphClient();
const data = await createQueries(client).getFileRelationships(filePath, parsedLimit.value ?? 100);
return c.json(data);
} catch (error) {
return c.json({
error: safeErrorMessage(
'GET /api/graph/file-relationships',
error,
'Failed to fetch file relationships.',
),
}, 500);
}
});

/** GET /api/graph/file?path=X — returns subgraph for a file */
graphRoutes.get('/api/graph/file', async (c) => {
try {
Expand Down Expand Up @@ -64,19 +122,21 @@ graphRoutes.get('/api/graph/references', async (c) => {
const name = c.req.query('name');
if (!name) return c.json({ error: 'name parameter is required' }, 400);

const rawLine = c.req.query('startLine');
const parsedLine = rawLine === undefined ? undefined : Number.parseInt(rawLine, 10);
const startLine = parsedLine !== undefined && Number.isFinite(parsedLine) ? parsedLine : undefined;
const parsedLine = boundedPositiveInteger(
c.req.query('startLine'),
'startLine',
REFERENCE_START_LINE_MAX,
);
if (!parsedLine.valid) return c.json({ error: parsedLine.error }, 400);

const rawLimit = c.req.query('limit');
const parsedLimit = rawLimit === undefined ? undefined : Number.parseInt(rawLimit, 10);
const limit = parsedLimit !== undefined && Number.isFinite(parsedLimit) ? parsedLimit : undefined;
const parsedLimit = boundedPositiveInteger(c.req.query('limit'), 'limit', REFERENCE_LIMIT_MAX);
if (!parsedLimit.valid) return c.json({ error: parsedLimit.error }, 400);

const data = await codeGraphService.getSymbolReferences({
name,
filePath: c.req.query('path'),
startLine,
limit,
startLine: parsedLine.value,
limit: parsedLimit.value,
});
return c.json(data);
} catch (error) {
Expand All @@ -92,7 +152,9 @@ graphRoutes.get('/api/graph/dependencies', async (c) => {
try {
const filePath = c.req.query('path');
if (!filePath) return c.json({ error: 'path parameter is required' }, 400);
const depth = Number(c.req.query('depth') ?? 3);
const parsedDepth = boundedPositiveInteger(c.req.query('depth'), 'depth', DEPENDENCY_DEPTH_MAX);
if (!parsedDepth.valid) return c.json({ error: parsedDepth.error }, 400);
const depth = parsedDepth.value ?? 3;
const data = await codeGraphService.getDependencyTree(filePath, depth);
return c.json(data);
} catch (error) {
Expand Down
Loading
Loading