GitNexus/gitnexus/test/unit/spring-bean-extractor.test.ts
MyShining fe3d7e56be
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / Classify release event (push) Waiting to run
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
feat(spring): detect non-HTTP handler entry points (#2891)
2026-08-16 15:16:21 +01:00

328 lines
11 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
collectJavaCaptureSideChannel,
type JavaCaptureSideChannel,
} from '../../src/core/ingestion/languages/java/capture-side-channel.js';
import { emitJavaScopeCaptures } from '../../src/core/ingestion/languages/java/captures.js';
import { javaScopeResolver } from '../../src/core/ingestion/languages/java/scope-resolver.js';
import { deriveSpringBeanMetadata } from '../../src/core/ingestion/frameworks/spring/bean-catalog.js';
import {
collectKotlinCaptureSideChannel,
type KotlinCaptureSideChannel,
} from '../../src/core/ingestion/languages/kotlin/capture-side-channel.js';
import { emitKotlinScopeCaptures } from '../../src/core/ingestion/languages/kotlin/captures.js';
import { kotlinScopeResolver } from '../../src/core/ingestion/languages/kotlin/scope-resolver.js';
function captureClassAnnotations(code: string): JavaCaptureSideChannel['classAnnotations'] {
const filePath = 'src/Test.java';
emitJavaScopeCaptures(code, filePath);
return collectJavaCaptureSideChannel(filePath)?.classAnnotations ?? [];
}
function captureSpringDiFacts(code: string): NonNullable<JavaCaptureSideChannel['springDiFacts']> {
const filePath = 'src/Test.java';
emitJavaScopeCaptures(code, filePath);
return collectJavaCaptureSideChannel(filePath)?.springDiFacts ?? [];
}
describe('Java class annotation capture', () => {
it('collects annotation names during the existing scope-query traversal', () => {
const facts = captureClassAnnotations(`
@Component("widget") class Widget {
@Deprecated @Service static class BillingService {}
}
@org.springframework.context.annotation.Configuration class AppConfiguration {}
@Service interface ServiceContract {}
@Service enum ServiceState { READY }
@Service record ServiceRecord(String value) {}
@Service @interface ServiceMarker {}
`);
expect(facts.map((fact) => fact.annotationNames)).toEqual([
['Component'],
['Deprecated', 'Service'],
['org.springframework.context.annotation.Configuration'],
]);
});
it('clears worker side-channel facts at the start of each workspace pass', async () => {
const filePath = 'src/Stale.java';
emitJavaScopeCaptures('@Service class Stale {}', filePath);
expect(collectJavaCaptureSideChannel(filePath)?.classAnnotations).toHaveLength(1);
await javaScopeResolver.loadResolutionConfig?.('/tmp/repo');
expect(collectJavaCaptureSideChannel(filePath)).toBeUndefined();
});
});
describe('Java Spring injection syntax capture', () => {
it('preserves constructor, field, method, qualifier, and bean-name syntax in the side channel', () => {
const facts = captureSpringDiFacts(`
@Service("checkout") class Checkout {
Checkout(@Qualifier("fastGateway") Gateway gateway) {}
@Autowired Gateway fallback;
@Inject void setRepo(Repo repo) {}
}
`);
expect(facts).toHaveLength(1);
expect(facts[0].classAnnotations).toEqual([
{ name: 'Service', text: '@Service("checkout")', line: 2 },
]);
expect(facts[0].injectionSites).toMatchObject([
{
kind: 'constructor',
implicitConstructor: true,
dependencies: [
{
name: 'gateway',
rawType: 'Gateway',
annotations: [{ name: 'Qualifier', text: '@Qualifier("fastGateway")' }],
},
],
},
{
kind: 'field',
memberName: 'fallback',
dependencies: [{ name: 'fallback', rawType: 'Gateway' }],
},
{
kind: 'method',
memberName: 'setRepo',
dependencies: [{ name: 'repo', rawType: 'Repo' }],
},
]);
});
});
function captureKotlinClassAnnotations(code: string): KotlinCaptureSideChannel['classAnnotations'] {
const filePath = 'src/Test.kt';
emitKotlinScopeCaptures(code, filePath);
return collectKotlinCaptureSideChannel(filePath)?.classAnnotations ?? [];
}
function captureKotlinSpringDiFacts(
code: string,
): NonNullable<KotlinCaptureSideChannel['springDiFacts']> {
const filePath = 'src/Test.kt';
emitKotlinScopeCaptures(code, filePath);
return collectKotlinCaptureSideChannel(filePath)?.springDiFacts ?? [];
}
function collectKotlinSpringNonHttpHandlerFactsFromSource(
code: string,
): NonNullable<KotlinCaptureSideChannel['springNonHttpHandlerFacts']> {
const filePath = 'src/Test.kt';
emitKotlinScopeCaptures(code, filePath);
return collectKotlinCaptureSideChannel(filePath)?.springNonHttpHandlerFacts ?? [];
}
describe('Kotlin class annotation capture', () => {
it('captures supported class forms and excludes non-candidate declarations', () => {
const facts = captureKotlinClassAnnotations(`
@Component class Widget
@Service("billing") data class BillingService(val name: String)
@org.springframework.context.annotation.Configuration sealed class AppConfiguration
@Service value class ServiceId(val value: String)
class Outer { @Service class NestedService }
@Service interface ServiceContract
@Service object ServiceObject
@Service enum class ServiceState { READY }
@Service annotation class ServiceMarker
`);
expect(facts.map((fact) => fact.annotationNames)).toEqual([
['Component'],
['Service'],
['org.springframework.context.annotation.Configuration'],
['Service'],
['Service'],
]);
});
it('clears annotation facts while preserving the Kotlin side-channel lifecycle', async () => {
const filePath = 'src/Stale.kt';
emitKotlinScopeCaptures('@Service class Stale', filePath);
expect(collectKotlinCaptureSideChannel(filePath)?.classAnnotations).toHaveLength(1);
await kotlinScopeResolver.loadResolutionConfig?.('/tmp/repo');
expect(collectKotlinCaptureSideChannel(filePath)).toBeUndefined();
});
});
describe('Kotlin Spring non-HTTP handler syntax capture', () => {
it('captures class-like owners while persisting only annotation resolution fields', () => {
const facts = collectKotlinSpringNonHttpHandlerFactsFromSource(`
class RegularHandlers {
@Scheduled fun regularHandler() {}
@receiver:EventListener fun String.targetedHandler() {}
}
object SingletonHandlers {
@KafkaListener(topics = ["orders"])
fun singletonHandler() {}
}
class CompanionHolder {
companion object {
@TransactionalEventListener fun companionHandler(event: Any) {}
}
}
enum class EnumHandlers {
READY;
@XxlJob("enum-handler") fun enumHandler() {}
}
`);
const annotations = facts
.flatMap((fact) => fact.annotations)
.sort((left, right) => left.name.localeCompare(right.name));
expect(annotations).toEqual([
{ name: 'EventListener', useSiteTarget: 'receiver' },
{ name: 'KafkaListener' },
{ name: 'Scheduled' },
{ name: 'TransactionalEventListener' },
{ name: 'XxlJob' },
]);
for (const annotation of annotations) {
expect(annotation).not.toHaveProperty('text');
expect(annotation).not.toHaveProperty('line');
}
});
});
describe('Kotlin Spring injection syntax capture', () => {
it('preserves primary constructor, property, method, nullable type, projection, and use-site syntax', () => {
const facts = captureKotlinSpringDiFacts(`
@Service("checkout") @Primary
class Checkout @Autowired constructor(
@param:Qualifier("fastGateway") private val gateway: PaymentGateway,
@Named("repo") repo: Repo?,
val gateways: List<out PaymentGateway>,
) {
@field:Autowired
@field:Qualifier("slowGateway")
lateinit var fallback: PaymentGateway
@set:Inject
var optional: Repo? = null
@Inject
fun setRepo(@Named("repo") repo: Repo) {}
}
`);
expect(facts).toHaveLength(1);
expect(facts[0].classAnnotations).toEqual([
{ name: 'Service', text: '@Service("checkout")', line: 2 },
{ name: 'Primary', text: '@Primary', line: 2 },
]);
expect(facts[0].injectionSites).toMatchObject([
{
kind: 'constructor',
implicitConstructor: false,
dependencies: [
{
name: 'gateway',
rawType: 'PaymentGateway',
annotations: [
{
name: 'Qualifier',
text: '@param:Qualifier("fastGateway")',
useSiteTarget: 'param',
},
],
},
{
name: 'repo',
rawType: 'Repo?',
annotations: [{ name: 'Named', text: '@Named("repo")' }],
},
{
name: 'gateways',
rawType: 'List<out PaymentGateway>',
},
],
},
{
kind: 'property',
memberName: 'fallback',
annotations: [
{ name: 'Autowired', text: '@field:Autowired', useSiteTarget: 'field' },
{
name: 'Qualifier',
text: '@field:Qualifier("slowGateway")',
useSiteTarget: 'field',
},
],
},
{
kind: 'property',
memberName: 'optional',
annotations: [{ name: 'Inject', text: '@set:Inject', useSiteTarget: 'set' }],
},
{
kind: 'method',
memberName: 'setRepo',
dependencies: [
{
name: 'repo',
rawType: 'Repo',
annotations: [{ name: 'Named', text: '@Named("repo")' }],
},
],
},
]);
});
it('captures sole stereotype primary constructors as implicit injection sites', () => {
const facts = captureKotlinSpringDiFacts(`
@Service
class Checkout(private val gateway: PaymentGateway)
`);
expect(facts[0].injectionSites).toMatchObject([
{
kind: 'constructor',
implicitConstructor: true,
dependencies: [{ name: 'gateway', rawType: 'PaymentGateway' }],
},
]);
});
});
describe('deriveSpringBeanMetadata', () => {
it('maps all supported canonical stereotypes to roles', () => {
const cases = [
['org.springframework.stereotype.Component', 'component'],
['org.springframework.stereotype.Service', 'service'],
['org.springframework.stereotype.Repository', 'repository'],
['org.springframework.stereotype.Controller', 'controller'],
['org.springframework.web.bind.annotation.RestController', 'rest-controller'],
['org.springframework.context.annotation.Configuration', 'configuration'],
] as const;
for (const [annotation, role] of cases) {
expect(deriveSpringBeanMetadata([annotation])).toEqual({
framework: 'spring',
role,
annotation,
});
}
});
it('omits conflicting or unsupported evidence', () => {
expect(
deriveSpringBeanMetadata([
'org.springframework.stereotype.Service',
'org.springframework.stereotype.Component',
]),
).toBeUndefined();
expect(deriveSpringBeanMetadata(['com.example.Service'])).toBeUndefined();
});
});