mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-07 08:26:11 +00:00
* feat(group): add GraphQL contract extraction * fix(group): tighten GraphQL contract guards * fix(group): complete GraphQL review hardening * fix(group): isolate bounded GraphQL reads * fix(group): harden GraphQL contract extraction
502 lines
17 KiB
TypeScript
502 lines
17 KiB
TypeScript
import * as fs from 'node:fs/promises';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import { parse } from 'graphql';
|
|
import Parser from 'tree-sitter';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import type { CypherExecutor } from '../../../src/core/group/contract-extractor.js';
|
|
import {
|
|
GraphqlExtractor,
|
|
indexGeneratedDeclarators,
|
|
} from '../../../src/core/group/extractors/graphql-extractor.js';
|
|
import type { RepoHandle } from '../../../src/core/group/types.js';
|
|
import { cleanupTempDir } from '../../helpers/test-db.js';
|
|
|
|
const tempDirs: string[] = [];
|
|
|
|
async function makeRepo(
|
|
files: Record<string, string>,
|
|
): Promise<{ root: string; repo: RepoHandle }> {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-graphql-'));
|
|
tempDirs.push(root);
|
|
for (const [relative, content] of Object.entries(files)) {
|
|
const absolute = path.join(root, relative);
|
|
await fs.mkdir(path.dirname(absolute), { recursive: true });
|
|
await fs.writeFile(absolute, content, 'utf8');
|
|
}
|
|
return {
|
|
root,
|
|
repo: { id: 'test', path: 'app', repoPath: root, storagePath: path.join(root, '.gitnexus') },
|
|
};
|
|
}
|
|
|
|
function executor(symbols: Record<string, Array<Record<string, unknown>>>): CypherExecutor {
|
|
return async (_query, params = {}) => {
|
|
const name = String(params.name ?? '');
|
|
const filePath = params.filePath ? `@${String(params.filePath)}` : '';
|
|
return symbols[`${name}${filePath}`] ?? symbols[name] ?? [];
|
|
};
|
|
}
|
|
|
|
function generatedDocument(
|
|
operation: 'query' | 'mutation' | 'subscription',
|
|
name: string,
|
|
fields: string[],
|
|
): string {
|
|
const selections = fields
|
|
.map((field) => `{ kind: 'Field', name: { kind: 'Name', value: '${field}' } }`)
|
|
.join(', ');
|
|
return `{ kind: 'Document', definitions: [{
|
|
kind: 'OperationDefinition',
|
|
operation: '${operation}',
|
|
name: { kind: 'Name', value: '${name}' },
|
|
selectionSet: { kind: 'SelectionSet', selections: [${selections}] }
|
|
}] }`;
|
|
}
|
|
|
|
describe('GraphqlExtractor', () => {
|
|
afterEach(async () => {
|
|
vi.restoreAllMocks();
|
|
await Promise.all(tempDirs.splice(0).map((dir) => cleanupTempDir(dir)));
|
|
});
|
|
|
|
it('anchors NestJS providers and document consumers to exact real symbols', async () => {
|
|
const { root, repo } = await makeRepo({
|
|
'src/widget.resolver.ts': `
|
|
import { Resolver, Query as GqlQuery, Mutation, Subscription } from '@nestjs/graphql';
|
|
@Resolver()
|
|
class WidgetResolver {
|
|
@GqlQuery(() => Widget, { name: 'widget' })
|
|
fetchWidget() { return null; }
|
|
@Mutation('saveWidget')
|
|
save() { return null; }
|
|
@Subscription()
|
|
widgetChanged() { return null; }
|
|
}`,
|
|
'src/widget.graphql': `
|
|
fragment WidgetRoot on Query { widget }
|
|
query GetWidget { alias: widget ...WidgetRoot }
|
|
mutation SaveWidget { saveWidget }
|
|
subscription WatchWidget { widgetChanged }
|
|
`,
|
|
'src/generated.ts': `
|
|
export const GetWidgetDocument = ${generatedDocument('query', 'GetWidget', ['widget'])};
|
|
export const SaveWidgetDocument = ${generatedDocument('mutation', 'SaveWidget', ['saveWidget'])};
|
|
export const WatchWidgetDocument = ${generatedDocument('subscription', 'WatchWidget', ['widgetChanged'])};
|
|
`,
|
|
});
|
|
const run = executor({
|
|
'fetchWidget@src/widget.resolver.ts': [
|
|
{ uid: 'method:fetch', name: 'fetchWidget', filePath: 'src/widget.resolver.ts' },
|
|
],
|
|
'save@src/widget.resolver.ts': [
|
|
{ uid: 'method:save', name: 'save', filePath: 'src/widget.resolver.ts' },
|
|
],
|
|
'widgetChanged@src/widget.resolver.ts': [
|
|
{ uid: 'method:watch', name: 'widgetChanged', filePath: 'src/widget.resolver.ts' },
|
|
],
|
|
GetWidgetDocument: [
|
|
{ uid: 'const:get', name: 'GetWidgetDocument', filePath: 'src/generated.ts' },
|
|
],
|
|
SaveWidgetDocument: [
|
|
{ uid: 'const:save', name: 'SaveWidgetDocument', filePath: 'src/generated.ts' },
|
|
],
|
|
WatchWidgetDocument: [
|
|
{ uid: 'const:watch', name: 'WatchWidgetDocument', filePath: 'src/generated.ts' },
|
|
],
|
|
});
|
|
|
|
const contracts = await new GraphqlExtractor().extract(run, root, repo);
|
|
|
|
expect(
|
|
contracts.map((contract) => [contract.contractId, contract.role, contract.symbolUid]),
|
|
).toEqual([
|
|
['graphql::query::widget', 'provider', 'method:fetch'],
|
|
['graphql::mutation::saveWidget', 'provider', 'method:save'],
|
|
['graphql::subscription::widgetChanged', 'provider', 'method:watch'],
|
|
['graphql::query::widget', 'consumer', 'const:get'],
|
|
['graphql::mutation::saveWidget', 'consumer', 'const:save'],
|
|
['graphql::subscription::widgetChanged', 'consumer', 'const:watch'],
|
|
]);
|
|
});
|
|
|
|
it('skips unproven decorators, ambiguous anchors, anonymous operations, and invalid documents', async () => {
|
|
const { root, repo } = await makeRepo({
|
|
'src/not-nest.ts': `
|
|
function Query(): MethodDecorator { return () => undefined; }
|
|
class LocalResolver { @Query() localOnly() {} }`,
|
|
'src/ambiguous.resolver.ts': `
|
|
import { Query, Resolver } from '@nestjs/graphql';
|
|
@Resolver()
|
|
class AmbiguousResolver { @Query() widget() {} }`,
|
|
'src/anonymous.graphql': `query { widget }`,
|
|
'src/invalid.graphql': `query Broken {`,
|
|
'src/missing.graphql': `query MissingGenerated { widget }`,
|
|
});
|
|
const ambiguous = [
|
|
{ uid: 'method:a', name: 'widget', filePath: 'src/ambiguous.resolver.ts' },
|
|
{ uid: 'method:b', name: 'widget', filePath: 'src/ambiguous.resolver.ts' },
|
|
];
|
|
|
|
const contracts = await new GraphqlExtractor().extract(
|
|
executor({ 'widget@src/ambiguous.resolver.ts': ambiguous }),
|
|
root,
|
|
repo,
|
|
);
|
|
|
|
expect(contracts).toEqual([]);
|
|
});
|
|
|
|
it('rejects provider fields that are not GraphQL Names', async () => {
|
|
const { root, repo } = await makeRepo({
|
|
'src/invalid.resolver.ts': `
|
|
import { Query, Resolver } from '@nestjs/graphql';
|
|
@Resolver()
|
|
class InvalidResolver {
|
|
@Query('bad::field') separator() {}
|
|
@Query('line\\nfeed') newline() {}
|
|
@Query('right\\u202etoLeft') bidi() {}
|
|
@Query('9startsWithDigit') digit() {}
|
|
}`,
|
|
});
|
|
let lookups = 0;
|
|
const run: CypherExecutor = async () => {
|
|
lookups++;
|
|
return [{ uid: 'method:invalid', name: 'invalid', filePath: 'src/invalid.resolver.ts' }];
|
|
};
|
|
|
|
expect(await new GraphqlExtractor().extract(run, root, repo)).toEqual([]);
|
|
expect(lookups).toBe(0);
|
|
});
|
|
|
|
it('skips a provider file whose AST exceeds the traversal depth cap', async () => {
|
|
const nested = `${'['.repeat(300)}0${']'.repeat(300)}`;
|
|
const { root, repo } = await makeRepo({
|
|
'src/deep.resolver.ts': `
|
|
import { Query, Resolver } from '@nestjs/graphql';
|
|
const nested = ${nested};
|
|
@Resolver()
|
|
class DeepResolver { @Query() health() {} }`,
|
|
});
|
|
let lookups = 0;
|
|
|
|
expect(
|
|
await new GraphqlExtractor().extract(
|
|
async () => {
|
|
lookups++;
|
|
return [{ uid: 'method:health', name: 'health', filePath: 'src/deep.resolver.ts' }];
|
|
},
|
|
root,
|
|
repo,
|
|
),
|
|
).toEqual([]);
|
|
expect(lookups).toBe(0);
|
|
});
|
|
|
|
it('uses an exact unique Document symbol when no generated hook exists', async () => {
|
|
const { root, repo } = await makeRepo({
|
|
'src/health.graphql': `query Health { health }`,
|
|
'src/generated/graphql.ts': `export const HealthDocument = ${generatedDocument('query', 'Health', ['health'])};`,
|
|
});
|
|
|
|
const contracts = await new GraphqlExtractor().extract(
|
|
executor({
|
|
HealthDocument: [
|
|
{ uid: 'var:health', name: 'HealthDocument', filePath: 'src/generated/graphql.ts' },
|
|
],
|
|
}),
|
|
root,
|
|
repo,
|
|
);
|
|
|
|
expect(contracts).toEqual([
|
|
expect.objectContaining({
|
|
contractId: 'graphql::query::health',
|
|
role: 'consumer',
|
|
symbolUid: 'var:health',
|
|
symbolRef: { filePath: 'src/generated/graphql.ts', name: 'HealthDocument' },
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it('skips matching tokens that occur only in unrelated initializer metadata', async () => {
|
|
const { root, repo } = await makeRepo({
|
|
'src/health.graphql': `query Health { health }`,
|
|
'src/generated/graphql.ts': `export const HealthDocument = {
|
|
metadata: { operation: 'Health', field: 'health' },
|
|
kind: 'NotADocument'
|
|
};`,
|
|
});
|
|
|
|
const contracts = await new GraphqlExtractor().extract(
|
|
executor({
|
|
HealthDocument: [
|
|
{ uid: 'var:health', name: 'HealthDocument', filePath: 'src/generated/graphql.ts' },
|
|
],
|
|
}),
|
|
root,
|
|
repo,
|
|
);
|
|
|
|
expect(contracts).toEqual([]);
|
|
});
|
|
|
|
it('fails closed when a generated Document initializer exceeds the AST depth budget', async () => {
|
|
const { root, repo } = await makeRepo({
|
|
'src/deep.graphql': `query Deep { health }`,
|
|
'src/generated.ts': `export const DeepDocument = ${'('.repeat(300)}${generatedDocument(
|
|
'query',
|
|
'Deep',
|
|
['health'],
|
|
)}${')'.repeat(300)};`,
|
|
});
|
|
|
|
const contracts = await new GraphqlExtractor().extract(
|
|
executor({
|
|
DeepDocument: [{ uid: 'const:deep', name: 'DeepDocument', filePath: 'src/generated.ts' }],
|
|
}),
|
|
root,
|
|
repo,
|
|
);
|
|
|
|
expect(contracts).toEqual([]);
|
|
});
|
|
|
|
it('fails closed for oversized, deeply nested, and excessive-operation documents', async () => {
|
|
const nested = `${'... on Query { '.repeat(65)}health${' }'.repeat(65)}`;
|
|
const manyOperations = Array.from(
|
|
{ length: 501 },
|
|
(_, index) => `query Op${index} { field${index} }`,
|
|
).join('\n');
|
|
const { root, repo } = await makeRepo({
|
|
'src/oversized.graphql': `${' '.repeat(1_000_001)}query Huge { huge }`,
|
|
'src/deep.graphql': `query Deep { ${nested} }`,
|
|
'src/many.graphql': manyOperations,
|
|
});
|
|
let lookups = 0;
|
|
const run: CypherExecutor = async (_query, params = {}) => {
|
|
lookups++;
|
|
const name = String(params.name ?? '');
|
|
return name.startsWith('useOp')
|
|
? [{ uid: `fn:${name}`, name, filePath: 'src/generated.ts' }]
|
|
: name === 'useDeepQuery'
|
|
? [{ uid: 'fn:deep', name, filePath: 'src/generated.ts' }]
|
|
: [];
|
|
};
|
|
|
|
const contracts = await new GraphqlExtractor().extract(run, root, repo);
|
|
|
|
expect(contracts).toEqual([]);
|
|
expect(contracts).not.toContainEqual(expect.objectContaining({ symbolUid: 'fn:deep' }));
|
|
expect(lookups).toBe(0);
|
|
});
|
|
|
|
it('keeps decorators across comments and supports decorated resolver properties', async () => {
|
|
const { root, repo } = await makeRepo({
|
|
'src/commented.resolver.ts': `
|
|
import { Query, Mutation, Resolver } from '@nestjs/graphql';
|
|
@Resolver()
|
|
export class CommentedResolver {
|
|
@Query()
|
|
/** Public schema description. */
|
|
health() { return true; }
|
|
|
|
@Mutation()
|
|
// The comment is not an ownership boundary.
|
|
save = async () => true;
|
|
}`,
|
|
});
|
|
|
|
const contracts = await new GraphqlExtractor().extract(
|
|
executor({
|
|
'health@src/commented.resolver.ts': [
|
|
{ uid: 'method:health', name: 'health', filePath: 'src/commented.resolver.ts' },
|
|
],
|
|
'save@src/commented.resolver.ts': [
|
|
{ uid: 'property:save', name: 'save', filePath: 'src/commented.resolver.ts' },
|
|
],
|
|
}),
|
|
root,
|
|
repo,
|
|
);
|
|
|
|
expect(contracts.map((contract) => contract.contractId)).toEqual([
|
|
'graphql::query::health',
|
|
'graphql::mutation::save',
|
|
]);
|
|
});
|
|
|
|
it('requires a top-level imported Resolver and skips dynamic field names', async () => {
|
|
const { root, repo } = await makeRepo({
|
|
'src/scoped.resolver.ts': `
|
|
import { Query, Resolver } from '@nestjs/graphql';
|
|
const FIELD = 'viewer';
|
|
const NAMES = { viewer: FIELD };
|
|
const name = FIELD;
|
|
const opts = { name: FIELD };
|
|
class Helper { @Query() helperOnly() {} }
|
|
function factory() {
|
|
@Resolver()
|
|
class NestedResolver { @Query() nestedOnly() {} }
|
|
return NestedResolver;
|
|
}
|
|
@Resolver()
|
|
class RealResolver {
|
|
@Query(() => String, { name: FIELD }) dynamicName() {}
|
|
@Query(() => String, { name: NAMES.viewer }) memberName() {}
|
|
@Query(() => String, { name }) shorthandName() {}
|
|
@Query(() => String, { ...opts }) spreadOptions() {}
|
|
@Query(() => String, { name: \`get\${FIELD}\` }) interpolatedName() {}
|
|
@Query(() => String, { name: 'get' + 'Widget' }) concatenatedName() {}
|
|
@Query(() => String) stableName() {}
|
|
}`,
|
|
});
|
|
const lookedUp: string[] = [];
|
|
const run: CypherExecutor = async (_query, params = {}) => {
|
|
lookedUp.push(String(params.name));
|
|
return [
|
|
{
|
|
uid: `method:${String(params.name)}`,
|
|
name: String(params.name),
|
|
filePath: 'src/scoped.resolver.ts',
|
|
},
|
|
];
|
|
};
|
|
|
|
const contracts = await new GraphqlExtractor().extract(run, root, repo);
|
|
|
|
expect(lookedUp).toEqual(['stableName']);
|
|
expect(contracts).toEqual([
|
|
expect.objectContaining({ contractId: 'graphql::query::stableName' }),
|
|
]);
|
|
});
|
|
|
|
it('does not extract co-located spec resolvers', async () => {
|
|
const { root, repo } = await makeRepo({
|
|
'src/widget.resolver.spec.ts': `
|
|
import { Query, Resolver } from '@nestjs/graphql';
|
|
@Resolver()
|
|
class MockResolver { @Query() widget() {} }`,
|
|
});
|
|
let lookups = 0;
|
|
|
|
const contracts = await new GraphqlExtractor().extract(
|
|
async () => {
|
|
lookups++;
|
|
return [{ uid: 'method:mock', name: 'widget', filePath: 'src/widget.resolver.spec.ts' }];
|
|
},
|
|
root,
|
|
repo,
|
|
);
|
|
|
|
expect(contracts).toEqual([]);
|
|
expect(lookups).toBe(0);
|
|
});
|
|
|
|
it('indexes a generated declaration after more than 100000 earlier AST nodes', () => {
|
|
const filler = { type: 'identifier', namedChildren: [] } as unknown as Parser.SyntaxNode;
|
|
const name = { text: 'LateDocument' } as Parser.SyntaxNode;
|
|
const value = { type: 'object', namedChildren: [] } as unknown as Parser.SyntaxNode;
|
|
const declaration = {
|
|
type: 'variable_declarator',
|
|
namedChildren: [],
|
|
childForFieldName: (field: string) =>
|
|
field === 'name' ? name : field === 'value' ? value : null,
|
|
} as unknown as Parser.SyntaxNode;
|
|
const root = {
|
|
type: 'program',
|
|
namedChildren: [...Array<Parser.SyntaxNode>(100_001).fill(filler), declaration],
|
|
} as unknown as Parser.SyntaxNode;
|
|
|
|
expect(indexGeneratedDeclarators(root).get('LateDocument')).toEqual([value]);
|
|
});
|
|
|
|
it('proves generated root fields through fragment spreads and inline fragments', async () => {
|
|
const { root, repo } = await makeRepo({
|
|
'src/widgets.graphql': `
|
|
query GetWidgets { widget ...MoreRoots ... on Query { inlineRoot } }
|
|
fragment MoreRoots on Query { gadget }
|
|
`,
|
|
'src/generated.ts': `
|
|
export const GetWidgetsDocument = ${JSON.stringify(
|
|
parse(`
|
|
query GetWidgets { widget ...MoreRoots ... on Query { inlineRoot } }
|
|
fragment MoreRoots on Query { gadget }
|
|
`),
|
|
)};`,
|
|
});
|
|
|
|
const contracts = await new GraphqlExtractor().extract(
|
|
executor({
|
|
GetWidgetsDocument: [
|
|
{ uid: 'const:widgets', name: 'GetWidgetsDocument', filePath: 'src/generated.ts' },
|
|
],
|
|
}),
|
|
root,
|
|
repo,
|
|
);
|
|
|
|
expect(contracts.map((contract) => contract.contractId).sort()).toEqual([
|
|
'graphql::query::gadget',
|
|
'graphql::query::inlineRoot',
|
|
'graphql::query::widget',
|
|
]);
|
|
});
|
|
|
|
it('accepts static gql tags and TypedDocumentString initializers', async () => {
|
|
const { root, repo } = await makeRepo({
|
|
'src/tagged.graphql': `query Tagged { tagged }`,
|
|
'src/string.graphql': `query StringMode { stringMode }`,
|
|
'src/generated.ts': `
|
|
export const TaggedDocument = gql\`query Tagged { tagged }\`;
|
|
export const StringModeDocument = new TypedDocumentString("query StringMode {\\n stringMode\\n}");
|
|
`,
|
|
});
|
|
|
|
const contracts = await new GraphqlExtractor().extract(
|
|
executor({
|
|
TaggedDocument: [
|
|
{ uid: 'const:tagged', name: 'TaggedDocument', filePath: 'src/generated.ts' },
|
|
],
|
|
StringModeDocument: [
|
|
{ uid: 'const:string', name: 'StringModeDocument', filePath: 'src/generated.ts' },
|
|
],
|
|
}),
|
|
root,
|
|
repo,
|
|
);
|
|
|
|
expect(contracts.map((contract) => contract.symbolUid).sort()).toEqual([
|
|
'const:string',
|
|
'const:tagged',
|
|
]);
|
|
});
|
|
|
|
it('parses one generated module once and continues past a shadowed candidate', async () => {
|
|
const { root, repo } = await makeRepo({
|
|
'src/one.graphql': `query One { one }`,
|
|
'src/two.graphql': `query Two { two }`,
|
|
'src/generated.ts': `
|
|
function shadow() { const OneDocument = { kind: 'NotADocument' }; }
|
|
export const OneDocument = ${generatedDocument('query', 'One', ['one'])};
|
|
export const TwoDocument = ${generatedDocument('query', 'Two', ['two'])};
|
|
`,
|
|
});
|
|
const parseSpy = vi.spyOn(Parser.prototype, 'parse');
|
|
|
|
const contracts = await new GraphqlExtractor().extract(
|
|
executor({
|
|
OneDocument: [{ uid: 'const:one', name: 'OneDocument', filePath: 'src/generated.ts' }],
|
|
TwoDocument: [{ uid: 'const:two', name: 'TwoDocument', filePath: 'src/generated.ts' }],
|
|
}),
|
|
root,
|
|
repo,
|
|
);
|
|
|
|
expect(contracts.map((contract) => contract.symbolUid).sort()).toEqual([
|
|
'const:one',
|
|
'const:two',
|
|
]);
|
|
expect(parseSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|