Initialisiere VoTiAn NG mit Docker-Build, .env-Konfiguration und Cookie-Sitzungen

This commit is contained in:
Sven committed 2026-10-05 17:45:38 +02:00
commit 1a30233269
63 files changed
+12138

No files matched your search

@@ -0,0 +1,243 @@
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<String> 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<String> 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<Ljava/sql/Connection;>;", 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<String> 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<String> dependencies(byte[] bytes) {
Set<String> 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<dynamic.getBootstrapMethodArgumentCount();i++) constant(dynamic.getBootstrapMethodArgument(i));
} else if (value instanceof String text) name(text.replace('.', '/'));
}
AnnotationVisitor annotation(String descriptor) {
descriptor(descriptor);
return new AnnotationVisitor(Opcodes.ASM9) {
@Override public void visit(String name, Object value) { constant(value); }
@Override public void visitEnum(String name, String descriptor, String value) { descriptor(descriptor); }
@Override public AnnotationVisitor visitAnnotation(String name, String descriptor) { return annotation(descriptor); }
@Override public AnnotationVisitor visitArray(String name) { return annotation(null); }
};
}
@Override public void visit(int version,int access,String name,String signature,String superName,String[] interfaces) {
name(superName);if(interfaces!=null)for(String iface:interfaces)name(iface);signature(signature,false);
}
@Override public AnnotationVisitor visitAnnotation(String descriptor,boolean visible) { return annotation(descriptor); }
@Override public RecordComponentVisitor visitRecordComponent(String name,String descriptor,String signature) {
descriptor(descriptor);signature(signature,true);
return new RecordComponentVisitor(Opcodes.ASM9) {
@Override public AnnotationVisitor visitAnnotation(String descriptor,boolean visible) { return annotation(descriptor); }
};
}
@Override public FieldVisitor visitField(int access,String name,String descriptor,String signature,Object value) {
descriptor(descriptor);signature(signature,true);constant(value);
return new FieldVisitor(Opcodes.ASM9) {
@Override public AnnotationVisitor visitAnnotation(String descriptor,boolean visible) { return annotation(descriptor); }
};
}
@Override public MethodVisitor visitMethod(int access,String name,String descriptor,String signature,String[] exceptions) {
descriptor(descriptor);signature(signature,false);if(exceptions!=null)for(String exception:exceptions)name(exception);
return new MethodVisitor(Opcodes.ASM9) {
@Override public AnnotationVisitor visitAnnotation(String descriptor,boolean visible) { return annotation(descriptor); }
@Override public AnnotationVisitor visitParameterAnnotation(int parameter,String descriptor,boolean visible) { return annotation(descriptor); }
@Override public void visitTypeInsn(int opcode,String type) { name(type); }
@Override public void visitFieldInsn(int opcode,String owner,String name,String descriptor) { name(owner);descriptor(descriptor); }
@Override public void visitMethodInsn(int opcode,String owner,String name,String descriptor,boolean isInterface) { name(owner);descriptor(descriptor); }
@Override public void visitInvokeDynamicInsn(String name,String descriptor,Handle bootstrap,Object... arguments) {
descriptor(descriptor);handle(bootstrap);for(Object argument:arguments)constant(argument);
}
@Override public void visitLdcInsn(Object value) { constant(value); }
@Override public void visitMultiANewArrayInsn(String descriptor,int dimensions) { descriptor(descriptor); }
@Override public void visitTryCatchBlock(org.springframework.asm.Label start,org.springframework.asm.Label end,org.springframework.asm.Label handler,String type) { name(type); }
};
}
}, ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES);
return forbidden;
}
}