Files
git.stella-ops.org/tools/stella-callgraph-node/index.test.js
StellaOps Bot 56e2dc01ee Add unit tests for AST parsing and security sink detection
- Created `StellaOps.AuditPack.Tests.csproj` for unit testing the AuditPack library.
- Implemented comprehensive unit tests in `index.test.js` for AST parsing, covering various JavaScript and TypeScript constructs including functions, classes, decorators, and JSX.
- Added `sink-detect.test.js` to test security sink detection patterns, validating command injection, SQL injection, file write, deserialization, SSRF, NoSQL injection, and more.
- Included tests for taint source detection in various contexts such as Express, Koa, and AWS Lambda.
2025-12-23 09:23:42 +02:00

676 lines
21 KiB
JavaScript

// -----------------------------------------------------------------------------
// index.test.js
// Sprint: SPRINT_3600_0004_0001 (Node.js Babel Integration)
// Tasks: NODE-017, NODE-018 - Unit tests for AST parsing and entrypoint detection
// Description: Tests for call graph extraction from JavaScript/TypeScript.
// -----------------------------------------------------------------------------
import { test, describe, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { parse } from '@babel/parser';
import traverse from '@babel/traverse';
// Test utilities for AST parsing
function parseCode(code, options = {}) {
return parse(code, {
sourceType: 'module',
plugins: [
'typescript',
'jsx',
'decorators-legacy',
'classProperties',
'classPrivateProperties',
'classPrivateMethods',
'dynamicImport',
'optionalChaining',
'nullishCoalescingOperator'
],
errorRecovery: true,
...options
});
}
describe('Babel Parser Integration', () => {
test('parses simple JavaScript function', () => {
const code = `
function hello(name) {
return 'Hello, ' + name;
}
`;
const ast = parseCode(code);
assert.ok(ast);
assert.equal(ast.type, 'File');
assert.ok(ast.program.body.length > 0);
});
test('parses arrow function', () => {
const code = `
const greet = (name) => {
return \`Hello, \${name}\`;
};
`;
const ast = parseCode(code);
assert.ok(ast);
let foundArrow = false;
traverse.default(ast, {
ArrowFunctionExpression() {
foundArrow = true;
}
});
assert.ok(foundArrow, 'Should find arrow function');
});
test('parses async function', () => {
const code = `
async function fetchData(url) {
const response = await fetch(url);
return response.json();
}
`;
const ast = parseCode(code);
let isAsync = false;
traverse.default(ast, {
FunctionDeclaration(path) {
isAsync = path.node.async;
}
});
assert.ok(isAsync, 'Should detect async function');
});
test('parses class with methods', () => {
const code = `
class UserController {
async getUser(id) {
return this.userService.findById(id);
}
async createUser(data) {
return this.userService.create(data);
}
}
`;
const ast = parseCode(code);
const methods = [];
traverse.default(ast, {
ClassMethod(path) {
methods.push(path.node.key.name);
}
});
assert.deepEqual(methods.sort(), ['createUser', 'getUser']);
});
test('parses TypeScript with types', () => {
const code = `
interface User {
id: string;
name: string;
}
function getUser(id: string): Promise<User> {
return db.query<User>('SELECT * FROM users WHERE id = $1', [id]);
}
`;
const ast = parseCode(code);
assert.ok(ast);
let foundFunction = false;
traverse.default(ast, {
FunctionDeclaration(path) {
if (path.node.id.name === 'getUser') {
foundFunction = true;
}
}
});
assert.ok(foundFunction, 'Should parse TypeScript function');
});
test('parses JSX components', () => {
const code = `
function Button({ onClick, children }) {
return <button onClick={onClick}>{children}</button>;
}
`;
const ast = parseCode(code);
let foundJSX = false;
traverse.default(ast, {
JSXElement() {
foundJSX = true;
}
});
assert.ok(foundJSX, 'Should parse JSX');
});
test('parses decorators', () => {
const code = `
@Controller('/users')
class UserController {
@Get('/:id')
async getUser(@Param('id') id: string) {
return this.userService.findById(id);
}
}
`;
const ast = parseCode(code);
const decorators = [];
traverse.default(ast, {
ClassDeclaration(path) {
if (path.node.decorators) {
decorators.push(...path.node.decorators.map(d =>
d.expression?.callee?.name || d.expression?.name
));
}
},
ClassMethod(path) {
if (path.node.decorators) {
decorators.push(...path.node.decorators.map(d =>
d.expression?.callee?.name || d.expression?.name
));
}
}
});
assert.ok(decorators.includes('Controller'));
assert.ok(decorators.includes('Get'));
});
test('parses dynamic imports', () => {
const code = `
async function loadModule(name) {
const module = await import(\`./modules/\${name}\`);
return module.default;
}
`;
const ast = parseCode(code);
let foundDynamicImport = false;
traverse.default(ast, {
Import() {
foundDynamicImport = true;
}
});
assert.ok(foundDynamicImport, 'Should detect dynamic import');
});
test('parses optional chaining', () => {
const code = `
const name = user?.profile?.name ?? 'Anonymous';
`;
const ast = parseCode(code);
let foundOptionalChain = false;
traverse.default(ast, {
OptionalMemberExpression() {
foundOptionalChain = true;
}
});
assert.ok(foundOptionalChain, 'Should parse optional chaining');
});
test('parses class private fields', () => {
const code = `
class Counter {
#count = 0;
increment() {
this.#count++;
}
get value() {
return this.#count;
}
}
`;
const ast = parseCode(code);
let foundPrivateField = false;
traverse.default(ast, {
ClassPrivateProperty() {
foundPrivateField = true;
}
});
assert.ok(foundPrivateField, 'Should parse private class field');
});
});
describe('Function Declaration Extraction', () => {
test('extracts function name', () => {
const code = `
function processRequest(req, res) {
res.json({ status: 'ok' });
}
`;
const ast = parseCode(code);
let functionName = null;
traverse.default(ast, {
FunctionDeclaration(path) {
functionName = path.node.id.name;
}
});
assert.equal(functionName, 'processRequest');
});
test('extracts function parameters', () => {
const code = `
function greet(firstName, lastName, options = {}) {
return \`Hello, \${firstName} \${lastName}\`;
}
`;
const ast = parseCode(code);
let params = [];
traverse.default(ast, {
FunctionDeclaration(path) {
params = path.node.params.map(p => {
if (p.type === 'Identifier') return p.name;
if (p.type === 'AssignmentPattern') return p.left.name;
return 'unknown';
});
}
});
assert.deepEqual(params, ['firstName', 'lastName', 'options']);
});
test('detects exported functions', () => {
const code = `
export function publicFunction() {}
function privateFunction() {}
export default function defaultFunction() {}
`;
const ast = parseCode(code);
const functions = { public: [], private: [] };
traverse.default(ast, {
FunctionDeclaration(path) {
const name = path.node.id?.name;
if (!name) return;
const isExported =
path.parent.type === 'ExportNamedDeclaration' ||
path.parent.type === 'ExportDefaultDeclaration';
if (isExported) {
functions.public.push(name);
} else {
functions.private.push(name);
}
}
});
assert.deepEqual(functions.public.sort(), ['defaultFunction', 'publicFunction']);
assert.deepEqual(functions.private, ['privateFunction']);
});
});
describe('Call Expression Extraction', () => {
test('extracts direct function calls', () => {
const code = `
function main() {
helper();
processData();
}
`;
const ast = parseCode(code);
const calls = [];
traverse.default(ast, {
CallExpression(path) {
if (path.node.callee.type === 'Identifier') {
calls.push(path.node.callee.name);
}
}
});
assert.deepEqual(calls.sort(), ['helper', 'processData']);
});
test('extracts method calls', () => {
const code = `
function handler() {
db.query('SELECT * FROM users');
fs.readFile('./config.json');
console.log('done');
}
`;
const ast = parseCode(code);
const methodCalls = [];
traverse.default(ast, {
CallExpression(path) {
if (path.node.callee.type === 'MemberExpression') {
const obj = path.node.callee.object.name;
const method = path.node.callee.property.name;
methodCalls.push(`${obj}.${method}`);
}
}
});
assert.ok(methodCalls.includes('db.query'));
assert.ok(methodCalls.includes('fs.readFile'));
assert.ok(methodCalls.includes('console.log'));
});
test('extracts chained method calls', () => {
const code = `
const result = data
.filter(x => x.active)
.map(x => x.name)
.join(', ');
`;
const ast = parseCode(code);
const methods = [];
traverse.default(ast, {
CallExpression(path) {
if (path.node.callee.type === 'MemberExpression') {
const method = path.node.callee.property.name;
methods.push(method);
}
}
});
assert.ok(methods.includes('filter'));
assert.ok(methods.includes('map'));
assert.ok(methods.includes('join'));
});
});
describe('Framework Entrypoint Detection', () => {
test('detects Express route handlers', () => {
const code = `
const express = require('express');
const app = express();
app.get('/users', (req, res) => {
res.json(users);
});
app.post('/users', async (req, res) => {
const user = await createUser(req.body);
res.json(user);
});
app.delete('/users/:id', (req, res) => {
deleteUser(req.params.id);
res.sendStatus(204);
});
`;
const ast = parseCode(code);
const routes = [];
traverse.default(ast, {
CallExpression(path) {
if (path.node.callee.type === 'MemberExpression') {
const method = path.node.callee.property.name?.toLowerCase();
const httpMethods = ['get', 'post', 'put', 'delete', 'patch'];
if (httpMethods.includes(method)) {
const routeArg = path.node.arguments[0];
if (routeArg?.type === 'StringLiteral') {
routes.push({ method: method.toUpperCase(), path: routeArg.value });
}
}
}
}
});
assert.equal(routes.length, 3);
assert.ok(routes.some(r => r.method === 'GET' && r.path === '/users'));
assert.ok(routes.some(r => r.method === 'POST' && r.path === '/users'));
assert.ok(routes.some(r => r.method === 'DELETE' && r.path === '/users/:id'));
});
test('detects Fastify route handlers', () => {
const code = `
const fastify = require('fastify')();
fastify.get('/health', async (request, reply) => {
return { status: 'ok' };
});
fastify.route({
method: 'POST',
url: '/items',
handler: async (request, reply) => {
return { id: 1 };
}
});
`;
const ast = parseCode(code);
const routes = [];
traverse.default(ast, {
CallExpression(path) {
if (path.node.callee.type === 'MemberExpression') {
const method = path.node.callee.property.name?.toLowerCase();
if (['get', 'post', 'put', 'delete', 'patch', 'route'].includes(method)) {
const routeArg = path.node.arguments[0];
if (routeArg?.type === 'StringLiteral') {
routes.push({ method: method.toUpperCase(), path: routeArg.value });
}
}
}
}
});
assert.ok(routes.some(r => r.path === '/health'));
});
test('detects NestJS controller decorators', () => {
const code = `
@Controller('users')
export class UsersController {
@Get()
findAll() {
return this.usersService.findAll();
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.usersService.findOne(id);
}
@Post()
create(@Body() createUserDto: CreateUserDto) {
return this.usersService.create(createUserDto);
}
}
`;
const ast = parseCode(code);
const handlers = [];
traverse.default(ast, {
ClassMethod(path) {
const decorators = path.node.decorators || [];
for (const decorator of decorators) {
const name = decorator.expression?.callee?.name || decorator.expression?.name;
if (['Get', 'Post', 'Put', 'Delete', 'Patch'].includes(name)) {
handlers.push({
method: name.toUpperCase(),
handler: path.node.key.name
});
}
}
}
});
assert.equal(handlers.length, 3);
assert.ok(handlers.some(h => h.handler === 'findAll'));
assert.ok(handlers.some(h => h.handler === 'findOne'));
assert.ok(handlers.some(h => h.handler === 'create'));
});
test('detects Koa router handlers', () => {
const code = `
const Router = require('koa-router');
const router = new Router();
router.get('/items', async (ctx) => {
ctx.body = await getItems();
});
router.post('/items', async (ctx) => {
ctx.body = await createItem(ctx.request.body);
});
`;
const ast = parseCode(code);
const routes = [];
traverse.default(ast, {
CallExpression(path) {
if (path.node.callee.type === 'MemberExpression') {
const objName = path.node.callee.object.name;
const method = path.node.callee.property.name?.toLowerCase();
if (objName === 'router' && ['get', 'post', 'put', 'delete'].includes(method)) {
const routeArg = path.node.arguments[0];
if (routeArg?.type === 'StringLiteral') {
routes.push({ method: method.toUpperCase(), path: routeArg.value });
}
}
}
}
});
assert.equal(routes.length, 2);
assert.ok(routes.some(r => r.method === 'GET' && r.path === '/items'));
assert.ok(routes.some(r => r.method === 'POST' && r.path === '/items'));
});
test('detects AWS Lambda handlers', () => {
const code = `
export const handler = async (event, context) => {
const body = JSON.parse(event.body);
return {
statusCode: 200,
body: JSON.stringify({ message: 'Success' })
};
};
export const main = async (event) => {
return { statusCode: 200 };
};
`;
const ast = parseCode(code);
const handlers = [];
traverse.default(ast, {
VariableDeclarator(path) {
const name = path.node.id?.name?.toLowerCase();
if (['handler', 'main'].includes(name)) {
if (path.node.init?.type === 'ArrowFunctionExpression') {
handlers.push(path.node.id.name);
}
}
}
});
assert.ok(handlers.includes('handler'));
assert.ok(handlers.includes('main'));
});
test('detects Hapi route handlers', () => {
const code = `
const server = Hapi.server({ port: 3000 });
server.route({
method: 'GET',
path: '/users',
handler: (request, h) => {
return getUsers();
}
});
server.route({
method: 'POST',
path: '/users',
handler: async (request, h) => {
return createUser(request.payload);
}
});
`;
const ast = parseCode(code);
let routeCount = 0;
traverse.default(ast, {
CallExpression(path) {
if (path.node.callee.type === 'MemberExpression') {
const method = path.node.callee.property.name;
if (method === 'route') {
routeCount++;
}
}
}
});
assert.equal(routeCount, 2);
});
});
describe('Module Import/Export Detection', () => {
test('detects CommonJS require', () => {
const code = `
const express = require('express');
const { Router } = require('express');
const db = require('./db');
`;
const ast = parseCode(code);
const imports = [];
traverse.default(ast, {
CallExpression(path) {
if (path.node.callee.name === 'require') {
const arg = path.node.arguments[0];
if (arg?.type === 'StringLiteral') {
imports.push(arg.value);
}
}
}
});
assert.ok(imports.includes('express'));
assert.ok(imports.includes('./db'));
});
test('detects ES module imports', () => {
const code = `
import express from 'express';
import { Router, Request, Response } from 'express';
import * as fs from 'fs';
import db from './db.js';
`;
const ast = parseCode(code);
const imports = [];
traverse.default(ast, {
ImportDeclaration(path) {
imports.push(path.node.source.value);
}
});
assert.ok(imports.includes('express'));
assert.ok(imports.includes('fs'));
assert.ok(imports.includes('./db.js'));
});
test('detects ES module exports', () => {
const code = `
export function publicFn() {}
export const publicConst = 42;
export default class MainClass {}
export { helper, utils };
`;
const ast = parseCode(code);
let exportCount = 0;
traverse.default(ast, {
ExportNamedDeclaration() { exportCount++; },
ExportDefaultDeclaration() { exportCount++; }
});
assert.ok(exportCount >= 3);
});
});