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
1 change: 1 addition & 0 deletions packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"@codegraph/core": "workspace:*",
"@codegraph/graph": "workspace:*",
"@codegraph/logger": "workspace:*",
"@codegraph/types": "workspace:*",
"@hono/node-server": "^1.19.14",
"hono": "^4.12.23"
},
Expand Down
56 changes: 56 additions & 0 deletions packages/api/src/__tests__/profile-route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* Route-level coverage for GET /api/profile's projectPath boundary safety.
*
* getProfile()'s own tests (profile.test.ts) prove the filter and validation
* logic in isolation. This file proves the Hono route actually wires that
* validation in: a relative projectPath must come back as a 400 with a
* plain-text error, and must never reach the graph. A pure-function test of
* validateProjectPath alone cannot see that wiring bug; only calling the
* route can (same reasoning as search-route.test.ts).
*
* `@codegraph/core` is mocked so this never touches a real graph.
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';

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

import { codeGraphService, getGraphClient } from '@codegraph/core';
import { profileRoutes } from '../routes/profile';

const mockedGetGraphStats = vi.mocked(codeGraphService.getGraphStats);
const mockedGetGraphClient = vi.mocked(getGraphClient);

describe('GET /api/profile: projectPath validation', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('returns 400 for a relative projectPath and never touches the graph', async () => {
const res = await profileRoutes.request('/api/profile?projectPath=relative/path');
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toMatch(/absolute/i);
expect(mockedGetGraphClient).not.toHaveBeenCalled();
});

it('returns 200 for an absolute projectPath', async () => {
mockedGetGraphStats.mockResolvedValue({
totalNodes: 1,
totalEdges: 0,
nodesByType: { File: 1 },
edgesByType: {},
largestFiles: [],
mostConnected: [],
} as never);
mockedGetGraphClient.mockResolvedValue({
roQuery: vi.fn().mockResolvedValue({ data: [], metadata: [] }),
} as never);

const res = await profileRoutes.request('/api/profile?projectPath=/abs/path');
expect(res.status).toBe(200);
});
});
131 changes: 127 additions & 4 deletions packages/api/src/__tests__/profile.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
import { describe, it, expect, vi } from 'vitest';
import { getProfile, type ProfileService } from '../routes/profile';
import { getProfile, validateProjectPath, type ProfileService } from '../routes/profile';

/**
* The true File node property set, as upserted by packages/graph/src/schema.ts
* (fileToNodeProps) and packages/graph/src/operations.ts (UPSERT_FILE,
* BATCH_UPSERT_FILES, BATCH_CREATE_FILES). File nodes have `filePath` and
* `extension`, never `path` or `language`, which do not exist on the node.
*/
const REAL_FILE_PROPERTIES = [
'filePath',
'name',
'extension',
'loc',
'lastModified',
'hash',
'sourcePipeline',
'sourceTask',
'processedAt',
];
const PHANTOM_FILE_PROPERTIES = ['f.path', 'f.language'];

describe('codebase profile', () => {
function makeMockService(overrides?: Partial<ProfileService>): ProfileService {
Expand All @@ -12,11 +31,11 @@ describe('codebase profile', () => {
if (cypher.includes('callCount')) {
return { data: [{ name: 'parseProject', callCount: 12 }] };
}
if (cypher.includes('f.language')) {
return { data: [{ name: 'TypeScript', fileCount: 98 }] };
if (cypher.includes('f.extension')) {
return { data: [{ extension: 'ts', fileCount: 98 }] };
}
if (cypher.includes('lastModified')) {
return { data: [{ filePath: '/src/x.ts', lastModified: Date.now() }] };
return { data: [{ filePath: '/src/x.ts', lastModified: '2026-08-01T00:00:00.000Z' }] };
}
if (cypher.includes('Entity')) {
return { data: [{ text: 'JWT auth', type: 'Decision', createdAt: Date.now() }] };
Expand Down Expand Up @@ -53,6 +72,38 @@ describe('codebase profile', () => {
it('populates recentFiles from query results', async () => {
const profile = await getProfile(makeMockService(), { projectPath: '/test' });
expect(profile.dynamic.recentFiles[0]?.filePath).toBe('/src/x.ts');
expect(profile.dynamic.recentFiles[0]?.lastModified).toBe('2026-08-01T00:00:00.000Z');
});

it('maps the extension-grouped languages query to display names', async () => {
const profile = await getProfile(makeMockService(), { projectPath: '/test' });
expect(profile.static.languages[0]?.name).toBe('TypeScript');
expect(profile.static.languages[0]?.fileCount).toBe(98);
});

it('never references phantom File properties (f.path, f.language) in generated Cypher', async () => {
const service = makeMockService();
await getProfile(service, { projectPath: '/test' });
const calls = (service.query as ReturnType<typeof vi.fn>).mock.calls;
for (const [cypher] of calls as [string, unknown][]) {
for (const phantom of PHANTOM_FILE_PROPERTIES) {
expect(cypher).not.toContain(phantom);
}
}
});

it('every f.<prop> reference in File-scoped queries is a real File node property', async () => {
const service = makeMockService();
await getProfile(service, { projectPath: '/test' });
const calls = (service.query as ReturnType<typeof vi.fn>).mock.calls;
for (const [cypher] of calls as [string, unknown][]) {
if (!cypher.includes('(f:File)')) continue;
const refs = cypher.match(/\bf\.(\w+)/g) ?? [];
for (const ref of refs) {
const prop = ref.slice(2);
expect(REAL_FILE_PROPERTIES).toContain(prop);
}
}
});

it('is fast — completes in under 200ms when service is mocked', async () => {
Expand Down Expand Up @@ -87,3 +138,75 @@ describe('codebase profile', () => {
}
});
});

describe('validateProjectPath', () => {
it('accepts an absolute path', () => {
expect(validateProjectPath('/abs/path')).toEqual({ valid: true });
});

it('accepts undefined (no filter requested)', () => {
expect(validateProjectPath(undefined)).toEqual({ valid: true });
});

it('rejects a relative path', () => {
const result = validateProjectPath('relative/path');
expect(result.valid).toBe(false);
});
});

describe('projectPath boundary safety', () => {
function makeMockService(overrides?: Partial<ProfileService>): ProfileService {
return {
getStats: vi.fn().mockResolvedValue({ nodes: 2310, edges: 5500, files: 142 }),
query: vi.fn().mockResolvedValue({ data: [] }),
...overrides,
};
}

it('rejects a relative projectPath instead of silently returning an empty profile', async () => {
await expect(
getProfile(makeMockService(), { projectPath: 'relative/path' }),
).rejects.toThrow(/absolute/i);
});

it('never calls the service when projectPath is relative', async () => {
const service = makeMockService();
await expect(getProfile(service, { projectPath: 'relative/path' })).rejects.toThrow();
expect(service.getStats).not.toHaveBeenCalled();
expect(service.query).not.toHaveBeenCalled();
});

it('normalizes a trailing slash before building the filter params', async () => {
const service = makeMockService();
await getProfile(service, { projectPath: '/tmp/x/project/' });
const calls = (service.query as ReturnType<typeof vi.fn>).mock.calls;
const fileCall = calls.find(([cypher]) => (cypher as string).includes('(f:File)'));
expect(fileCall).toBeDefined();
const [, params] = fileCall as [string, Record<string, unknown>];
expect(params['projectPath']).toBe('/tmp/x/project');
expect(params['projectPathPrefix']).toBe('/tmp/x/project/');
});

it('the generated filter cannot match a sibling directory sharing the same prefix', async () => {
const service = makeMockService();
await getProfile(service, { projectPath: '/tmp/x/project' });
const calls = (service.query as ReturnType<typeof vi.fn>).mock.calls;
const fileCall = calls.find(([cypher]) => (cypher as string).includes('(f:File)'));
expect(fileCall).toBeDefined();
const [, params] = fileCall as [string, Record<string, unknown>];

// Reproduces FalkorDB's `= $projectPath OR STARTS WITH $projectPathPrefix`
// semantics against the exact params the code built, so this fails if the
// implementation ever regresses to a plain STARTS WITH.
const matchesFilter = (filePath: string): boolean =>
filePath === params['projectPath'] || filePath.startsWith(params['projectPathPrefix'] as string);

// The leak this guards against: a plain `STARTS WITH "/tmp/x/project"`
// also matches the unrelated sibling directory "/tmp/x/project-extra".
expect(matchesFilter('/tmp/x/project-extra/leaked.ts')).toBe(false);
// A real file inside the project must still match.
expect(matchesFilter('/tmp/x/project/src/index.ts')).toBe(true);
// The project root itself must match too.
expect(matchesFilter('/tmp/x/project')).toBe(true);
});
});
15 changes: 10 additions & 5 deletions packages/api/src/__tests__/search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,18 @@

import { describe, it, expect } from 'vitest';
import { resolveTypeFilter, typeFilterNotice } from '../routes/search';
import { SYMBOL_LABELS } from '@codegraph/types';

// Mirrors what /api/embeddings/status reports for this repository's own
// indexed graph: the seven embeddable code-symbol types, plus Commit,
// indexed graph: the seven embeddable code-symbol types (SYMBOL_LABELS,
// the shared source of truth in packages/types/src/labels.ts), plus Commit,
// TypeRef, Project and Metadata, which the vector-search allowlist never
// covered but the Cypher fallback path has always been able to match.
const KNOWN_LABELS = new Set([
'File', 'Function', 'Class', 'Interface', 'Variable', 'Type', 'Component',
// covered but the Cypher fallback path has always been able to match. The
// extra four are graph-structure labels specific to this fixture, not a
// canonical subset, so they stay spelled out here rather than living in
// @codegraph/types.
const KNOWN_LABELS = new Set<string>([
...SYMBOL_LABELS,
'Commit', 'TypeRef', 'Project', 'Metadata',
]);

Expand Down Expand Up @@ -74,7 +79,7 @@ describe('resolveTypeFilter', () => {
});

it('accepts every label the fallback query defaults to', () => {
for (const label of ['File', 'Function', 'Class', 'Interface', 'Variable', 'Type', 'Component']) {
for (const label of SYMBOL_LABELS) {
expect(resolveTypeFilter(label, KNOWN_LABELS)).toEqual({ kind: 'labels', labels: [label] });
}
});
Expand Down
Loading
Loading