mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
771 lines
27 KiB
TypeScript
771 lines
27 KiB
TypeScript
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);
|
|
});
|