package de.stadtbote.votian; import org.junit.jupiter.api.Test; import org.springframework.asm.AnnotationVisitor; import org.springframework.asm.ClassReader; import org.springframework.asm.ClassVisitor; import org.springframework.asm.ClassWriter; import org.springframework.asm.ConstantDynamic; import org.springframework.asm.FieldVisitor; import org.springframework.asm.Handle; import org.springframework.asm.MethodVisitor; import org.springframework.asm.Opcodes; import org.springframework.asm.RecordComponentVisitor; import org.springframework.asm.Type; import net.bytebuddy.jar.asm.signature.SignatureReader; import net.bytebuddy.jar.asm.signature.SignatureVisitor; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.Set; import java.util.TreeSet; import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.assertThat; /** Enforces the database boundary using compiled dependencies, including fully qualified references. */ class DatabaseAccessArchitectureTest { private static final String BASE = "de/stadtbote/votian/"; private static final String SERVICE = BASE + "database/DatabaseService"; private static final String SERVICE_TEST = BASE + "database/DatabaseServiceTest"; private static final String THIS_TEST = BASE + "DatabaseAccessArchitectureTest"; private static final List FORBIDDEN_PREFIXES = List.of( "java/sql/", "javax/sql/", "org/springframework/jdbc/", "org/springframework/transaction/", "org/springframework/orm/", "org/springframework/data/", "jakarta/persistence/", "javax/persistence/", "org/hibernate/", "com/zaxxer/hikari/", "org/mariadb/jdbc/", "com/mysql/", "org/postgresql/", "org/h2/", "org/apache/commons/dbcp", "org/apache/tomcat/jdbc/" ); // Timestamp is a value already returned by JDBC, not a connection or execution API. private static final Set ALLOWED_VALUE_TYPES = Set.of("java/sql/Timestamp"); @Test void productionDatabaseAccessGoesThroughOneService() throws Exception { assertNoBypasses(classesDirectory(VotianApplication.class), false); } @Test void testsUseTheSameBoundaryExceptTheServiceOwnUnitTests() throws Exception { assertNoBypasses(classesDirectory(DatabaseAccessArchitectureTest.class), true); } @Test void bytecodeGuardDetectsConstructorFieldsCallsAndReflectiveTypeNames() { ClassWriter writer = new ClassWriter(0); writer.visit(Opcodes.V21, Opcodes.ACC_PUBLIC, BASE + "ForbiddenAccess", null, "java/lang/Object", null); writer.visitField(Opcodes.ACC_PRIVATE, "template", "Lorg/springframework/jdbc/core/JdbcTemplate;", null, null).visitEnd(); MethodVisitor method = writer.visitMethod(Opcodes.ACC_PUBLIC, "read", "(Ljavax/sql/DataSource;)V", null, null); method.visitCode(); method.visitMethodInsn(Opcodes.INVOKESTATIC, "java/sql/DriverManager", "getConnection", "(Ljava/lang/String;)Ljava/sql/Connection;", false); method.visitLdcInsn("jakarta.persistence.EntityManager"); method.visitInsn(Opcodes.RETURN); method.visitMaxs(2, 2); method.visitEnd(); writer.visitEnd(); var findings = dependencies(writer.toByteArray()); assertThat(findings).contains("org/springframework/jdbc/core/JdbcTemplate", "javax/sql/DataSource", "java/sql/DriverManager", "java/sql/Connection", "jakarta/persistence/EntityManager"); } @Test void timestampValueMappingRemainsAllowedButGenericJdbcDependenciesDoNot() { ClassWriter writer = new ClassWriter(0); writer.visit(Opcodes.V21, Opcodes.ACC_PUBLIC, BASE + "ValueMapping", null, "java/lang/Object", null); writer.visitField(Opcodes.ACC_PRIVATE, "timestamp", "Ljava/sql/Timestamp;", null, null).visitEnd(); writer.visitEnd(); assertThat(dependencies(writer.toByteArray())).isEmpty(); writer = new ClassWriter(0); writer.visit(Opcodes.V21, Opcodes.ACC_PUBLIC, BASE + "GenericBypass", null, "java/lang/Object", null); writer.visitField(Opcodes.ACC_PRIVATE, "connections", "Ljava/util/List;", "Ljava/util/List;", null).visitEnd(); writer.visitEnd(); assertThat(dependencies(writer.toByteArray())).containsExactly("java/sql/Connection"); } @Test void pythonLaunchersDoNotOpenTheirOwnDatabaseConnections() throws Exception { Path scripts = projectRoot().resolve("scripts"); assertThat(scripts).isDirectory(); // Parse source only: do not execute/import launchers or read PHP/local connection files. String checker = """ import ast, pathlib, sys drivers = {'pymysql', 'MySQLdb', 'mysql', 'mariadb', 'psycopg', 'psycopg2', 'sqlite3', 'aiosqlite', 'sqlalchemy', 'asyncpg', 'pg8000', 'pyodbc', 'jaydebeapi', 'adodbapi', 'peewee', 'tortoise', 'databases', 'duckdb'} db_commands = {'mysql', 'mariadb', 'psql', 'sqlite3', 'sqlcmd', 'isql'} def forbidden(module): return bool(module) and module.split('.')[0] in drivers def violations(source): problems = [] for node in ast.walk(ast.parse(source)): if isinstance(node, ast.Import): problems += [(node.lineno, item.name) for item in node.names if forbidden(item.name)] elif isinstance(node, ast.ImportFrom) and forbidden(node.module): problems.append((node.lineno, node.module)) elif isinstance(node, ast.Call) and node.args: function = node.func.id if isinstance(node.func, ast.Name) else node.func.attr if isinstance(node.func, ast.Attribute) else '' first = node.args[0] if function in {'__import__', 'import_module'} and isinstance(first, ast.Constant) and isinstance(first.value, str) and forbidden(first.value): problems.append((node.lineno, first.value)) elif function in {'run', 'Popen', 'call', 'check_call', 'check_output', 'system', 'execvp', 'execvpe'}: command = first.elts[0] if isinstance(first, (ast.List, ast.Tuple)) and first.elts else first if isinstance(command, ast.Constant) and isinstance(command.value, str): executable = pathlib.Path(command.value.split()[0]).name if command.value.split() else '' if executable in db_commands: problems.append((node.lineno, executable)) return problems assert violations('import pymysql as database') assert violations('from mysql import connector') assert violations("importlib.import_module('mariadb')") assert violations("subprocess.run(['mysql', '--version'])") assert not violations("import json; subprocess.run(['java', '-jar', 'app.jar'])") findings = [] for path in sorted(pathlib.Path(sys.argv[1]).rglob('*.py')): for line, name in violations(path.read_text(encoding='utf-8')): findings.append(f'{path.name}:{line}: direct database dependency {name}') print('\\n'.join(findings)) sys.exit(1 if findings else 0) """; Process process = new ProcessBuilder("python3", "-c", checker, scripts.toString()).redirectErrorStream(true).start(); boolean finished = process.waitFor(30, TimeUnit.SECONDS); if (!finished) process.destroyForcibly(); assertThat(finished).as("Python AST architecture check must terminate without starting the application").isTrue(); String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); assertThat(process.exitValue()).as("Python launchers must delegate database work to the Java DatabaseService:%n%s", output).isZero(); } private static Path projectRoot() { Path directory = Path.of("").toAbsolutePath().normalize(); for (int level = 0; directory != null && level < 5; level++, directory = directory.getParent()) { if (Files.isDirectory(directory.resolve("backend")) && Files.isDirectory(directory.resolve("scripts"))) return directory; } throw new AssertionError("Cannot locate the project scripts directory from Maven working directory"); } private static Path classesDirectory(Class anchor) throws Exception { Path classes = Path.of(anchor.getProtectionDomain().getCodeSource().getLocation().toURI()); assertThat(classes).as("Architecture check expects compiled class directories").isDirectory(); return classes; } private static boolean belongsTo(String name, String owner) { return name.equals(owner) || name.startsWith(owner + "$"); } private static void assertNoBypasses(Path classes, boolean tests) throws IOException { List violations = new ArrayList<>(); if (!tests) assertThat(classes.resolve(SERVICE + ".class")).as("The central database service must exist").isRegularFile(); try (var paths = Files.walk(classes)) { for (Path path : paths.filter(p -> p.toString().endsWith(".class")).sorted().toList()) { byte[] bytes = Files.readAllBytes(path); String name = new ClassReader(bytes).getClassName(); boolean exempt = belongsTo(name, SERVICE) || (tests && (belongsTo(name, SERVICE_TEST) || belongsTo(name, THIS_TEST))); if (!exempt) for (String dependency : dependencies(bytes)) violations.add(name.replace('/', '.') + " -> " + dependency.replace('/', '.')); } } assertThat(violations).as("Every DB-access type must remain inside DatabaseService (only DatabaseServiceTest may mock raw JDBC)").isEmpty(); } private static Set dependencies(byte[] bytes) { Set forbidden = new TreeSet<>(); new ClassReader(bytes).accept(new ClassVisitor(Opcodes.ASM9) { void name(String name) { if (name == null) return; if (name.startsWith("[")) { descriptor(name); return; } if (!ALLOWED_VALUE_TYPES.contains(name) && FORBIDDEN_PREFIXES.stream().anyMatch(name::startsWith)) forbidden.add(name); } void type(Type type) { switch (type.getSort()) { case Type.OBJECT -> name(type.getInternalName()); case Type.ARRAY -> type(type.getElementType()); case Type.METHOD -> { type(type.getReturnType()); for (Type arg : type.getArgumentTypes()) type(arg); } default -> { } } } void descriptor(String descriptor) { if (descriptor != null) type(Type.getType(descriptor)); } void signature(String signature, boolean field) { if (signature == null) return; var visitor = new SignatureVisitor(Opcodes.ASM9) { @Override public void visitClassType(String name) { name(name); } }; if (field) new SignatureReader(signature).acceptType(visitor); else new SignatureReader(signature).accept(visitor); } void handle(Handle handle) { name(handle.getOwner()); descriptor(handle.getDesc()); } void constant(Object value) { if (value instanceof Type type) type(type); else if (value instanceof Handle handle) handle(handle); else if (value instanceof ConstantDynamic dynamic) { descriptor(dynamic.getDescriptor());handle(dynamic.getBootstrapMethod()); for (int i=0;i