import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import type { GraphNode } from 'gitnexus-shared'; import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; const SECRET_ENV_VALUE = 'ACTUATOR_ENV_SECRET_2418'; const SECRET_CONFIG_VALUE = 'ACTUATOR_CONFIG_SECRET_2418'; const SECRET_CONDITION_MESSAGE = 'runtime condition details must stay private'; function writeFixture(root: string, relativePath: string, content: string): void { const target = path.join(root, relativePath); fs.mkdirSync(path.dirname(target), { recursive: true }); fs.writeFileSync(target, content); } function writeJson(root: string, relativePath: string, value: unknown): void { writeFixture(root, relativePath, JSON.stringify(value)); } function runtimeFixture(): { repo: string; actuatorDir: string } { const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-spring-actuator-')); writeFixture( repo, 'src/main/java/com/example/RuntimeApplication.java', `package com.example; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.stereotype.Service; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @RestController class OrderController { @GetMapping("/orders") String list() { return helper(); } String helper() { return leaf(); } String leaf() { return "ok"; } String lookup(String id) { return id; } String lookup(Integer id) { return id.toString(); } String tags(String... values) { return String.join(",", values); } } class SiblingController { String list() { return "not-the-handler"; } } @Service class BillingService {} @Configuration class RuntimeConfig { @Bean @ConditionalOnProperty(prefix = "app.billing", name = "enabled") BillingService billingService() { return new BillingService(); } } class SiblingConfig { BillingService billingService() { return new BillingService(); } } @ConfigurationProperties(prefix = "app.billing") class BillingProperties { String url; } `, ); writeFixture( repo, 'src/main/resources/application.properties', 'app.billing.enabled=true\napp.billing.url=https://static.example\n', ); const actuatorDir = path.join(repo, 'runtime-actuator'); writeJson(repo, 'runtime-actuator/mappings.json', { contexts: { application: { mappings: { dispatcherServlets: { dispatcherServlet: [ { predicate: '{GET [/orders]}', details: { handlerMethod: { className: 'com.example.OrderController', name: 'list', descriptor: '()Ljava/lang/String;', }, requestMappingConditions: { methods: ['GET'], patterns: ['/orders'] }, }, }, { predicate: '{GET [/runtime-bound]}', details: { handlerMethod: { className: 'com.example.OrderController', name: 'list', descriptor: '()Ljava/lang/String;', }, requestMappingConditions: { methods: ['GET'], patterns: ['/runtime-bound'], }, }, }, { predicate: '{POST [/runtime-only]}', details: { handlerMethod: { className: 'com.vendor.RuntimeController', name: 'create', descriptor: '()V', }, requestMappingConditions: { methods: ['POST'], patterns: ['/runtime-only'], }, }, }, { predicate: '{GET [/runtime-overload]}', details: { handlerMethod: { className: 'com.example.OrderController', name: 'lookup', descriptor: '(Ljava/lang/String;)Ljava/lang/String;', }, requestMappingConditions: { methods: ['GET'], patterns: ['/runtime-overload'], }, }, }, { predicate: '{GET [/runtime-varargs]}', details: { handlerMethod: { className: 'com.example.OrderController', name: 'tags', descriptor: '([Ljava/lang/String;)Ljava/lang/String;', }, requestMappingConditions: { methods: ['GET'], patterns: ['/runtime-varargs'], }, }, }, { predicate: '{ [/GET]}', details: { handlerMethod: { className: 'com.vendor.RuntimeController', name: 'create', descriptor: '()V', }, }, }, ], }, }, }, }, }); writeJson(repo, 'runtime-actuator/beans.json', { contexts: { application: { beans: { billingService: { type: 'com.example.BillingService', scope: 'singleton', dependencies: [], }, runtimeOnlyBean: { type: 'com.vendor.RuntimeOnlyBean', scope: 'singleton', dependencies: [], }, }, }, }, }); writeJson(repo, 'runtime-actuator/conditions.json', { contexts: { application: { positiveMatches: { 'com.example.RuntimeConfig#billingService': [ { condition: 'OnPropertyCondition', message: SECRET_CONDITION_MESSAGE }, ], 'com.example.RuntimeConfig': [ { condition: 'OnClassCondition', message: 'runtime configuration matched' }, ], }, negativeMatches: {}, }, }, }); writeJson(repo, 'runtime-actuator/configprops.json', { contexts: { application: { beans: { billing: { prefix: 'app.billing', properties: { url: SECRET_CONFIG_VALUE }, inputs: { url: { value: SECRET_CONFIG_VALUE, origin: 'systemEnvironment' }, enabled: { value: true }, }, }, }, }, }, }); writeJson(repo, 'runtime-actuator/env.json', { activeProfiles: [], propertySources: [ { name: 'systemEnvironment', properties: { 'app.billing.url': { value: SECRET_ENV_VALUE, origin: 'env' }, 'db.password': { value: SECRET_ENV_VALUE, origin: 'env' }, }, }, ], }); return { repo, actuatorDir }; } describe('Spring Boot Actuator runtime enrichment (#2418)', () => { const tempRepos: string[] = []; afterEach(() => { for (const repo of tempRepos.splice(0)) { fs.rmSync(repo, { recursive: true, force: true }); } }); it('is disabled by default and does not add runtime evidence', async () => { const { repo } = runtimeFixture(); tempRepos.push(repo); const result = await runPipelineFromRepo(repo, () => {}, { skipGraphPhases: true }); expect( [...result.graph.iterNodes()].some( (node) => node.properties.runtimeSource === 'spring-actuator', ), ).toBe(false); expect( [...result.graph.iterRelationships()].some((edge) => edge.reason.startsWith('spring-actuator:'), ), ).toBe(false); }, 60_000); it('confirms static routes/beans/properties, adds runtime-only evidence, and drops values', async () => { const { repo, actuatorDir } = runtimeFixture(); tempRepos.push(repo); const result = await runPipelineFromRepo(repo, () => {}, { skipGraphPhases: true, springActuatorPath: actuatorDir, }); const nodes = [...result.graph.iterNodes()]; const nodeNamed = (name: string, label?: GraphNode['label']): GraphNode | undefined => nodes.find( (node) => node.properties.name === name && (label === undefined || node.label === label), ); expect(nodeNamed('/orders', 'Route')?.properties).toMatchObject({ method: 'GET', runtimeConfirmed: true, runtimeSource: 'spring-actuator', }); expect(nodeNamed('/runtime-only', 'Route')?.properties).toMatchObject({ method: 'POST', runtimeConfirmed: true, }); expect(nodeNamed('/GET', 'Route')?.properties.method).toBeUndefined(); expect(nodeNamed('/GET', 'Route')?.properties.runtimeConfirmed).toBe(true); const owner = nodeNamed('OrderController', 'Class'); const ownerMethodId = [...result.graph.iterRelationshipsByType('HAS_METHOD')].find( (edge) => edge.sourceId === owner?.id && result.graph.getNode(edge.targetId)?.properties.name === 'list', )?.targetId; expect(nodeNamed('/runtime-bound', 'Route')?.properties.handlerSymbolId).toBe(ownerMethodId); const overloadHandler = result.graph.getNode( String(nodeNamed('/runtime-overload', 'Route')?.properties.handlerSymbolId), ); expect(overloadHandler?.properties).toMatchObject({ name: 'lookup', parameterTypes: ['String'], }); const varargsHandler = result.graph.getNode( String(nodeNamed('/runtime-varargs', 'Route')?.properties.handlerSymbolId), ); expect(varargsHandler?.properties).toMatchObject({ name: 'tags', parameterTypes: ['String'], }); expect(nodeNamed('BillingService', 'Class')?.properties.runtimeConfirmed).toBeUndefined(); expect(nodeNamed('BillingService', 'Class')?.properties.description).toContain( 'Spring Actuator beans runtime-confirmed', ); expect( nodeNamed('runtimeOnlyBean', 'CodeElement')?.properties.runtimeConfirmed, ).toBeUndefined(); expect(nodeNamed('runtimeOnlyBean', 'CodeElement')?.properties.description).toContain( 'Spring Actuator beans runtime-confirmed', ); const configOwner = nodeNamed('RuntimeConfig', 'Class'); expect(configOwner?.properties.description).toContain('Spring Actuator conditions matched'); const configMethodId = [...result.graph.iterRelationshipsByType('HAS_METHOD')].find( (edge) => edge.sourceId === configOwner?.id && result.graph.getNode(edge.targetId)?.properties.name === 'billingService', )?.targetId; expect(result.graph.getNode(configMethodId ?? '')?.properties.runtimeStatus).toBeUndefined(); expect(result.graph.getNode(configMethodId ?? '')?.properties.description).toContain( 'Spring Actuator conditions matched', ); const siblingOwner = nodeNamed('SiblingConfig', 'Class'); const siblingMethodId = [...result.graph.iterRelationshipsByType('HAS_METHOD')].find( (edge) => edge.sourceId === siblingOwner?.id && result.graph.getNode(edge.targetId)?.properties.name === 'billingService', )?.targetId; expect(result.graph.getNode(siblingMethodId ?? '')?.properties.runtimeStatus).toBeUndefined(); expect(nodeNamed('app.billing.url', 'Property')?.properties.runtimeConfirmed).toBeUndefined(); expect(nodeNamed('app.billing.url', 'Property')?.properties.description).toContain( 'Spring Actuator configprops runtime-confirmed', ); expect(nodeNamed('db.password', 'Property')?.properties.runtimeConfirmed).toBeUndefined(); expect(nodeNamed('db.password', 'Property')?.properties.description).toContain( 'Spring Actuator env runtime-confirmed', ); const graphText = JSON.stringify({ nodes, relationships: [...result.graph.iterRelationships()], }); expect(graphText).not.toContain(SECRET_ENV_VALUE); expect(graphText).not.toContain(SECRET_CONFIG_VALUE); expect(graphText).not.toContain(SECRET_CONDITION_MESSAGE); expect(graphText).not.toContain('systemEnvironment'); // The configured snapshot directory is excluded before source ingestion, // so env/configprops values cannot leak into File-node content or FTS. expect( nodes.some((node) => String(node.properties.filePath).includes('runtime-actuator/')), ).toBe(false); expect( [...result.graph.iterRelationshipsByType('DECLARES')].some((edge) => edge.reason.startsWith('spring-actuator:mappings:'), ), ).toBe(true); }, 60_000); it('does not runtime-confirm a named provider whose declared type disagrees', async () => { const { repo, actuatorDir } = runtimeFixture(); tempRepos.push(repo); writeJson(repo, 'runtime-actuator/beans.json', { contexts: { application: { beans: { billingService: { type: 'com.vendor.StaleBillingService', scope: 'singleton', dependencies: [], }, }, }, }, }); const result = await runPipelineFromRepo(repo, () => {}, { skipGraphPhases: true, springActuatorPath: actuatorDir, }); const nodes = [...result.graph.iterNodes()]; const declaredProvider = nodes.find( (node) => node.properties.name === 'billingService' && node.properties.springDiProvider !== undefined, ); expect(declaredProvider?.properties.description).not.toContain( 'Spring Actuator beans runtime-confirmed', ); expect( nodes.some( (node) => node.properties.name === 'billingService' && node.properties.qualifiedName === 'com.vendor.StaleBillingService' && String(node.properties.description).includes('Spring Actuator beans runtime-confirmed'), ), ).toBe(true); }, 60_000); it('reports invalid JSON without echoing payload source text', async () => { const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-spring-actuator-invalid-')); tempRepos.push(repo); writeFixture(repo, 'index.ts', 'export const value = 1;\n'); writeFixture(repo, 'actuator/env.json', `{"value":"${SECRET_ENV_VALUE}`); await expect( runPipelineFromRepo(repo, () => {}, { skipGraphPhases: true, springActuatorPath: 'actuator', }), ).rejects.toSatisfy( (error: unknown) => error instanceof Error && error.message.includes('not valid JSON') && !error.message.includes(SECRET_ENV_VALUE), ); }, 60_000); it('accepts a bundle file and excludes that file from source ingestion', async () => { const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-spring-actuator-bundle-')); tempRepos.push(repo); writeFixture(repo, 'index.ts', 'export const value = 1;\n'); writeJson(repo, 'runtime-snapshot.json', { env: { propertySources: [ { name: 'bundle-source', properties: { 'bundle.secret-key': { value: SECRET_ENV_VALUE } }, }, ], }, }); const result = await runPipelineFromRepo(repo, () => {}, { skipGraphPhases: true, springActuatorPath: 'runtime-snapshot.json', }); const nodes = [...result.graph.iterNodes()]; expect( nodes.find( (node) => node.label === 'Property' && node.properties.name === 'bundle.secret-key', )?.properties.description, ).toContain('Spring Actuator env runtime-confirmed'); expect(nodes.some((node) => node.properties.filePath === 'runtime-snapshot.json')).toBe(false); expect(JSON.stringify(nodes)).not.toContain(SECRET_ENV_VALUE); expect(JSON.stringify(nodes)).not.toContain('bundle-source'); }, 60_000); it('keeps repository sources when the Actuator directory is the repository root', async () => { const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-spring-actuator-root-')); tempRepos.push(repo); writeFixture(repo, 'index.ts', 'export const retained = true;\n'); writeJson(repo, 'mappings.json', { contexts: {} }); const result = await runPipelineFromRepo(repo, () => {}, { skipGraphPhases: true, springActuatorPath: repo, }); const files = [...result.graph.iterNodes()].filter((node) => node.label === 'File'); expect(files.some((node) => node.properties.filePath === 'index.ts')).toBe(true); expect(files.some((node) => node.properties.filePath === 'mappings.json')).toBe(false); }, 60_000); it('keeps repository sources when the Actuator directory is an ancestor', async () => { const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-spring-actuator-parent-')); tempRepos.push(parent); const repo = path.join(parent, 'repo'); fs.mkdirSync(repo); writeFixture(repo, 'index.ts', 'export const retained = true;\n'); writeJson(parent, 'mappings.json', { contexts: {} }); const result = await runPipelineFromRepo(repo, () => {}, { skipGraphPhases: true, springActuatorPath: parent, }); expect( [...result.graph.iterNodes()].some( (node) => node.label === 'File' && node.properties.filePath === 'index.ts', ), ).toBe(true); }, 60_000); it('excludes an in-repo Actuator dump addressed through a directory symlink', async () => { if (process.platform === 'win32') return; const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-spring-actuator-symlink-')); tempRepos.push(repo); writeFixture(repo, 'index.ts', 'export const retained = true;\n'); writeJson(repo, 'runtime-dumps/env.json', { propertySources: [ { name: 'systemEnvironment', properties: { 'symlink.secret': { value: SECRET_ENV_VALUE } }, }, ], }); fs.symlinkSync('runtime-dumps', path.join(repo, 'actuator'), 'dir'); const result = await runPipelineFromRepo(repo, () => {}, { skipGraphPhases: true, springActuatorPath: 'actuator', }); const nodes = [...result.graph.iterNodes()]; expect( nodes.some((node) => node.label === 'Property' && node.properties.name === 'symlink.secret'), ).toBe(true); expect(nodes.some((node) => node.properties.filePath === 'runtime-dumps/env.json')).toBe(false); expect(JSON.stringify(nodes)).not.toContain(SECRET_ENV_VALUE); }, 60_000); it('links an Actuator-only route with a resolved source handler to its execution flow', async () => { const { repo, actuatorDir } = runtimeFixture(); tempRepos.push(repo); const result = await runPipelineFromRepo(repo, () => {}, { springActuatorPath: actuatorDir, }); const route = [...result.graph.iterNodes()].find( (node) => node.label === 'Route' && node.properties.name === '/runtime-bound', ); const entryEdges = [...result.graph.iterRelationshipsByType('ENTRY_POINT_OF')].filter( (edge) => edge.sourceId === route?.id, ); expect(route?.properties.handlerSymbolId).toEqual(expect.any(String)); expect(entryEdges.length).toBeGreaterThan(0); expect( entryEdges.some( (edge) => result.graph.getNode(edge.targetId)?.properties.entryPointId === route?.properties.handlerSymbolId, ), ).toBe(true); }, 60_000); it('keeps triple duplicate simple names ambiguous and rejects stale qualified packages', async () => { const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-spring-actuator-ambiguous-')); tempRepos.push(repo); for (const pkg of ['one', 'two', 'three']) { writeFixture( repo, `src/main/java/${pkg}/DuplicateController.java`, `package ${pkg}; class DuplicateController { String run() { return "${pkg}"; } }\n`, ); } writeFixture( repo, 'src/main/java/source/AdminController.java', 'package source; class AdminController { String run() { return "source"; } }\n', ); writeJson(repo, 'actuator/mappings.json', { contexts: { application: { mappings: { dispatcherServlets: { dispatcherServlet: [ { predicate: '{GET [/ambiguous]}', details: { handlerMethod: { className: 'DuplicateController', name: 'run', descriptor: '()Ljava/lang/String;', }, requestMappingConditions: { methods: ['GET'], patterns: ['/ambiguous'] }, }, }, { predicate: '{GET [/stale-package]}', details: { handlerMethod: { className: 'runtime.AdminController', name: 'run', descriptor: '()Ljava/lang/String;', }, requestMappingConditions: { methods: ['GET'], patterns: ['/stale-package'], }, }, }, ], }, }, }, }, }); const result = await runPipelineFromRepo(repo, () => {}, { skipGraphPhases: true, springActuatorPath: 'actuator', }); const routes = [...result.graph.iterNodes()].filter((node) => node.label === 'Route'); expect( routes.find((node) => node.properties.name === '/ambiguous')?.properties, ).not.toHaveProperty('handlerSymbolId'); expect( routes.find((node) => node.properties.name === '/stale-package')?.properties, ).not.toHaveProperty('handlerSymbolId'); }, 60_000); it('records a static/runtime handler conflict without confirming the wrong owner', async () => { const { repo } = runtimeFixture(); tempRepos.push(repo); writeFixture( repo, 'src/main/java/com/example/OtherController.java', 'package com.example; class OtherController { String otherList() { return "other"; } }\n', ); writeJson(repo, 'runtime-actuator/mappings.json', { contexts: { application: { mappings: { dispatcherServlets: { dispatcherServlet: [ { predicate: '{GET [/orders]}', details: { handlerMethod: { className: 'com.example.OtherController', name: 'otherList', descriptor: '()Ljava/lang/String;', }, requestMappingConditions: { methods: ['GET'], patterns: ['/orders'] }, }, }, ], }, }, }, }, }); const result = await runPipelineFromRepo(repo, () => {}, { skipGraphPhases: true, springActuatorPath: 'runtime-actuator', }); const route = [...result.graph.iterNodes()].find( (node) => node.label === 'Route' && node.properties.name === '/orders', ); const otherFile = [...result.graph.iterNodes()].find( (node) => node.label === 'File' && node.properties.filePath.endsWith('OtherController.java'), ); expect(route?.properties.filePath).toContain('RuntimeApplication.java'); expect(route?.properties.handlerSymbolId).not.toBe( [...result.graph.iterNodes()].find( (node) => node.label === 'Method' && node.properties.name === 'otherList' && node.properties.filePath.endsWith('OtherController.java'), )?.id, ); expect(route?.properties).toMatchObject({ runtimeConfirmed: false, runtimeSource: 'spring-actuator', runtimeStatus: 'handler-conflict', }); expect( [...result.graph.iterRelationshipsByType('HANDLES_ROUTE')].some( (edge) => edge.sourceId === otherFile?.id && edge.targetId === route?.id, ), ).toBe(false); }, 60_000); it('marks duplicate runtime mappings with different handlers as a conflict', async () => { const { repo } = runtimeFixture(); tempRepos.push(repo); const mapping = (className: string) => ({ predicate: '{GET [/context-conflict]}', details: { handlerMethod: { className, name: 'list', descriptor: '()Ljava/lang/String;', }, requestMappingConditions: { methods: ['GET'], patterns: ['/context-conflict'] }, }, }); writeJson(repo, 'runtime-actuator/mappings.json', { contexts: { parent: { mappings: { dispatcherServlets: { dispatcherServlet: [mapping('com.example.OrderController')], }, }, }, child: { mappings: { dispatcherServlets: { dispatcherServlet: [mapping('com.example.SiblingController')], }, }, }, }, }); const result = await runPipelineFromRepo(repo, () => {}, { skipGraphPhases: true, springActuatorPath: 'runtime-actuator', }); const route = [...result.graph.iterNodes()].find( (node) => node.label === 'Route' && node.properties.name === '/context-conflict', ); expect(route?.properties).toMatchObject({ runtimeConfirmed: false, runtimeSource: 'spring-actuator', runtimeStatus: 'handler-conflict', }); expect(route?.properties).not.toHaveProperty('handlerSymbolId'); expect( [...result.graph.iterRelationshipsByType('HANDLES_ROUTE')].some( (edge) => edge.targetId === route?.id, ), ).toBe(false); }, 60_000); it('keeps aggregate negative condition status on the owner instead of every child target', async () => { const { repo } = runtimeFixture(); tempRepos.push(repo); writeJson(repo, 'runtime-actuator/conditions.json', { contexts: { application: { positiveMatches: {}, negativeMatches: { 'com.example.RuntimeConfig#billingService': { notMatched: [{ condition: 'OnPropertyCondition', message: 'did not match' }], matched: [{ condition: 'OnClassCondition', message: 'did match' }], }, }, }, }, }); const result = await runPipelineFromRepo(repo, () => {}, { skipGraphPhases: true, springActuatorPath: 'runtime-actuator', }); const owner = [...result.graph.iterNodes()].find( (node) => node.label === 'Class' && node.properties.name === 'RuntimeConfig', ); const methodId = [...result.graph.iterRelationshipsByType('HAS_METHOD')].find( (edge) => edge.sourceId === owner?.id && result.graph.getNode(edge.targetId)?.properties.name === 'billingService', )?.targetId; const conditionTargets = [...result.graph.iterRelationshipsByType('CONDITIONAL_ON')] .filter((edge) => edge.sourceId === methodId) .map((edge) => result.graph.getNode(edge.targetId)); expect(result.graph.getNode(methodId ?? '')?.properties.description).toContain( 'Spring Actuator conditions not-matched', ); expect(conditionTargets.length).toBeGreaterThan(0); expect( conditionTargets.some((node) => String(node?.properties.description ?? '').includes( 'Spring Actuator conditions not-matched', ), ), ).toBe(false); }, 60_000); });