Initialisiere VoTiAn NG mit Docker-Build, .env-Konfiguration und Cookie-Sitzungen
This commit is contained in:
commit
1a30233269
63 files changed
+12138
No files matched your search
@@ -0,0 +1,191 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.dao.DataAccessResourceFailureException;
|
||||
import org.springframework.mock.web.MockHttpSession;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
@SpringBootTest(properties={"spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration"})
|
||||
@AutoConfigureMockMvc
|
||||
class ApiSecurityTest {
|
||||
@Autowired MockMvc mvc;
|
||||
@Autowired ObjectMapper json;
|
||||
@MockitoBean DatabaseService database;
|
||||
|
||||
record Csrf(MockHttpSession session, String token, String header) {}
|
||||
|
||||
@BeforeEach void configure() {
|
||||
when(database.checkConnection()).thenReturn(new DatabaseService.ConnectionCheck(true,"CONNECTED","Verbunden"));
|
||||
when(database.queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),eq("existing-user"),eq("existing-password"),eq("existing-password")))
|
||||
.thenReturn(List.of(Map.of("usr_id",11,"emp_id",12,"hq_id",7,"usr_account","existing-user","emp_rights","111111111111")));
|
||||
}
|
||||
|
||||
Csrf csrf(MockHttpSession session) throws Exception {
|
||||
var request = get("/api/csrf");
|
||||
if (session != null) request.session(session);
|
||||
MvcResult response = mvc.perform(request).andExpect(status().isOk())
|
||||
.andExpect(header().string("Cache-Control", org.hamcrest.Matchers.containsString("no-store")))
|
||||
.andReturn();
|
||||
var body = json.readTree(response.getResponse().getContentAsString());
|
||||
return new Csrf((MockHttpSession) response.getRequest().getSession(false), body.get("token").asText(), body.get("headerName").asText());
|
||||
}
|
||||
|
||||
MvcResult login(Csrf csrf, String username, String password, int expectedStatus) throws Exception {
|
||||
// Represents upstream htaccess authentication: VoTiAn must ignore this header.
|
||||
return mvc.perform(post("/api/login").session(csrf.session()).header(csrf.header(),csrf.token())
|
||||
.with(httpBasic("proxy-fixture","proxy-password"))
|
||||
.contentType("application/x-www-form-urlencoded").param("username",username).param("password",password))
|
||||
.andExpect(status().is(expectedStatus)).andExpect(header().doesNotExist("WWW-Authenticate")).andReturn();
|
||||
}
|
||||
|
||||
MockHttpSession authenticated(String username, String password) throws Exception {
|
||||
return (MockHttpSession) login(csrf(null),username,password,200).getRequest().getSession(false);
|
||||
}
|
||||
|
||||
@Test void healthAndSystemArePublicButDataRequiresSession() throws Exception {
|
||||
mvc.perform(get("/api/health").with(httpBasic("proxy-fixture","proxy-password")))
|
||||
.andExpect(status().isOk()).andExpect(jsonPath("$.status").value("UP"));
|
||||
mvc.perform(get("/api/system")).andExpect(status().isOk()).andExpect(jsonPath("$.readOnly").value(true))
|
||||
.andExpect(jsonPath("$.schemaChanges").value(false)).andExpect(jsonPath("$.capabilities.writeOrders").value(false));
|
||||
MvcResult unauthorized = mvc.perform(get("/api/orders")).andExpect(status().isUnauthorized())
|
||||
.andExpect(header().doesNotExist("WWW-Authenticate")).andReturn();
|
||||
assertThat(unauthorized.getRequest().getSession(false)).isNull();
|
||||
Csrf csrf = csrf(null);
|
||||
mvc.perform(post("/api/orders").session(csrf.session()).header(csrf.header(),csrf.token())
|
||||
.contentType("application/json").content("{}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test void existingLoginPersistsSessionRotatesIdAndStoresNoPassword() throws Exception {
|
||||
Csrf anonymous = csrf(null);
|
||||
String previousId = anonymous.session().getId();
|
||||
MvcResult login = login(anonymous,"existing-user","existing-password",200);
|
||||
var body = json.readTree(login.getResponse().getContentAsString());
|
||||
assertThat(body.get("username").asText()).isEqualTo("existing-user");
|
||||
assertThat(body.has("password")).isFalse();
|
||||
MockHttpSession session = (MockHttpSession) login.getRequest().getSession(false);
|
||||
assertThat(session.getId()).isNotEqualTo(previousId);
|
||||
SecurityContext context = (SecurityContext) session.getAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY);
|
||||
assertThat(context.getAuthentication().getCredentials()).isNull();
|
||||
mvc.perform(get("/api/me").session(session)).andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.headquartersId").value(7)).andExpect(jsonPath("$.roles[0]").value("VIEWER"))
|
||||
.andExpect(jsonPath("$.password").doesNotExist()).andExpect(jsonPath("$.capabilities.exportOrders").value(true))
|
||||
.andExpect(jsonPath("$.capabilities.writeOrders").value(false));
|
||||
mvc.perform(get("/api/me").session(session).with(httpBasic("proxy-fixture","proxy-password")))
|
||||
.andExpect(status().isOk()).andExpect(jsonPath("$.username").value("existing-user"));
|
||||
verify(database,times(1)).queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),any(),any(),any());
|
||||
}
|
||||
|
||||
@Test void basicCredentialsAloneCannotAuthenticateTheApplication() throws Exception {
|
||||
mvc.perform(get("/api/me").with(httpBasic("existing-user","existing-password")))
|
||||
.andExpect(status().isUnauthorized()).andExpect(header().doesNotExist("WWW-Authenticate"));
|
||||
verify(database,never()).queryForList(anyString(),any(),any(),any());
|
||||
}
|
||||
|
||||
@Test void loginRequiresCsrfAndDoesNotAcceptCredentialsInGetQuery() throws Exception {
|
||||
mvc.perform(post("/api/login").param("username","existing-user").param("password","existing-password"))
|
||||
.andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("CSRF_INVALID"));
|
||||
mvc.perform(get("/api/login").param("username","existing-user").param("password","existing-password"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
verify(database,never()).queryForList(anyString(),any(),any(),any());
|
||||
}
|
||||
|
||||
@Test void loginRotatesCsrfAndLogoutInvalidatesSession() throws Exception {
|
||||
Csrf anonymous = csrf(null);
|
||||
MockHttpSession session = (MockHttpSession) login(anonymous,"existing-user","existing-password",200).getRequest().getSession(false);
|
||||
mvc.perform(post("/api/logout").session(session).header(anonymous.header(),anonymous.token()))
|
||||
.andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("CSRF_INVALID"));
|
||||
mvc.perform(get("/api/me").session(session)).andExpect(status().isOk());
|
||||
Csrf authenticated = csrf(session);
|
||||
mvc.perform(post("/api/logout").session(session).header(authenticated.header(),authenticated.token()))
|
||||
.andExpect(status().isNoContent()).andExpect(cookie().maxAge("VOTIANSESSION",0));
|
||||
assertThat(session.isInvalid()).isTrue();
|
||||
mvc.perform(get("/api/me")).andExpect(status().isUnauthorized());
|
||||
Csrf afterLogout = csrf(null);
|
||||
mvc.perform(post("/api/login").session(afterLogout.session()).header(authenticated.header(),authenticated.token())
|
||||
.param("username","existing-user").param("password","existing-password"))
|
||||
.andExpect(status().isForbidden());
|
||||
login(afterLogout,"existing-user","existing-password",200);
|
||||
}
|
||||
|
||||
@Test void logoutRequiresCsrfAndGetCannotLogOut() throws Exception {
|
||||
MockHttpSession session = authenticated("existing-user","existing-password");
|
||||
mvc.perform(post("/api/logout").session(session)).andExpect(status().isForbidden());
|
||||
mvc.perform(get("/api/logout").session(session)).andExpect(status().isForbidden());
|
||||
mvc.perform(get("/api/me").session(session)).andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test void csrfCannotBeReusedAcrossSessions() throws Exception {
|
||||
Csrf first = csrf(null);
|
||||
Csrf second = csrf(null);
|
||||
mvc.perform(post("/api/login").session(second.session()).header(first.header(),first.token())
|
||||
.param("username","existing-user").param("password","existing-password"))
|
||||
.andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("CSRF_INVALID"));
|
||||
verify(database,never()).queryForList(anyString(),any(),any(),any());
|
||||
}
|
||||
|
||||
@Test void noWriteEndpointCanModifyTheDatabase() throws Exception {
|
||||
MockHttpSession session = authenticated("existing-user","existing-password");
|
||||
Csrf csrf = csrf(session);
|
||||
mvc.perform(post("/api/orders/12/cancel").session(session).header(csrf.header(),csrf.token())
|
||||
.contentType("application/json").content("{\"version\":1,\"cancelMode\":\"FREE\",\"reason\":\"test\"}"))
|
||||
.andExpect(status().isNotImplemented());
|
||||
verify(database,never()).update(anyString(),any(Object[].class));
|
||||
}
|
||||
|
||||
@Test void backendDatabaseFailureIs503NotFakeLoginOrEmptyDataset() throws Exception {
|
||||
when(database.queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),eq("offline"),eq("password"),eq("password")))
|
||||
.thenThrow(new DataAccessResourceFailureException("server secret must not be exposed"));
|
||||
var result = login(csrf(null),"offline","password",503);
|
||||
assertThat(json.readTree(result.getResponse().getContentAsString()).get("message").asText()).isEqualTo(ApiErrors.DATABASE_UNAVAILABLE);
|
||||
assertThat(result.getRequest().getSession(false).getAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY)).isNull();
|
||||
when(database.checkConnection()).thenReturn(new DatabaseService.ConnectionCheck(false,"UNAVAILABLE",ApiErrors.DATABASE_UNAVAILABLE));
|
||||
mvc.perform(get("/api/system")).andExpect(status().isOk()).andExpect(jsonPath("$.connected").value(false));
|
||||
}
|
||||
|
||||
@Test void wrongOrUnscopedAccountDoesNotAuthenticate() throws Exception {
|
||||
Csrf wrong = csrf(null);
|
||||
login(wrong,"unknown","incorrect",401);
|
||||
mvc.perform(get("/api/me").session(wrong.session())).andExpect(status().isUnauthorized());
|
||||
when(database.queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),eq("invalid"),eq("password"),eq("password")))
|
||||
.thenReturn(List.of(Map.of("usr_id",11,"emp_id",12,"hq_id",0,"usr_account","invalid","emp_rights","111111")));
|
||||
login(csrf(null),"invalid","password",401);
|
||||
}
|
||||
|
||||
@Test void rightsAreEnforcedBeyondAuthentication() throws Exception {
|
||||
when(database.queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),eq("restricted"),eq("password"),eq("password")))
|
||||
.thenReturn(List.of(Map.of("usr_id",11,"emp_id",12,"hq_id",7,"usr_account","restricted","emp_rights","0000000000")));
|
||||
MockHttpSession session = authenticated("restricted","password");
|
||||
mvc.perform(get("/api/me").session(session)).andExpect(status().isOk()).andExpect(jsonPath("$.capabilities.exportOrders").value(false));
|
||||
mvc.perform(get("/api/orders").session(session)).andExpect(status().isForbidden());
|
||||
mvc.perform(get("/api/customers").session(session)).andExpect(status().isForbidden());
|
||||
mvc.perform(get("/api/orders/export.csv").session(session)).andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test void databaseAccountDenialIsDistinguishedFromNetworkFailure() throws Exception {
|
||||
when(database.checkConnection()).thenReturn(new DatabaseService.ConnectionCheck(false,"AUTHENTICATION_FAILED","Bitte Hostfreigabe prüfen."));
|
||||
mvc.perform(get("/api/system")).andExpect(status().isOk()).andExpect(jsonPath("$.connected").value(false))
|
||||
.andExpect(jsonPath("$.reason").value("AUTHENTICATION_FAILED"))
|
||||
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("Hostfreigabe")));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import java.util.List;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
/** Opt-in SELECT-only check against the configured existing database. No accounts or data are created. */
|
||||
@EnabledIfEnvironmentVariable(named="VOTIAN_VERIFY_EXISTING_DB", matches="true")
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc(printOnlyOnFailure=false, print=org.springframework.boot.test.autoconfigure.web.servlet.MockMvcPrint.NONE)
|
||||
class LegacyDatabaseReadIntegrationTest {
|
||||
@Autowired DatabaseService database;
|
||||
@Autowired MockMvc mvc;
|
||||
|
||||
@Test void readsExistingListsThroughHttpWithNumericLegacyFlags() throws Exception {
|
||||
// The session default is not the active transaction's access mode. Enforced READ ONLY
|
||||
// is verified at the transaction boundary in DatabaseServiceTest, without test writes.
|
||||
assertThat(database.checkConnection().connected()).isTrue();
|
||||
// Select only a scope ID. Authentication is simulated in this test; no real account is used.
|
||||
Long hq=database.queryForObject("SELECT hq_id FROM job ORDER BY jb_ordertime DESC LIMIT 1",Long.class);
|
||||
assertThat(hq).isPositive();
|
||||
var principal=new LegacyUser(0,0,hq,"read-only-integration-test","111111");
|
||||
var auth=UsernamePasswordAuthenticationToken.authenticated(principal,null,List.of());
|
||||
for(String route:List.of("/api/orders","/api/customers","/api/couriers")) {
|
||||
mvc.perform(get(route).with(authentication(auth)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$").isArray())
|
||||
.andExpect(jsonPath("$[0].id").isNumber());
|
||||
}
|
||||
Object archived=database.queryForObject("SELECT cmp_archived FROM company WHERE cmp_archived IS NOT NULL LIMIT 1",Object.class);
|
||||
assertThat(archived).isInstanceOf(Number.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static de.stadtbote.votian.Models.*;
|
||||
|
||||
class LegacyMappingTest {
|
||||
@Test void mapsBooleanCompanyFlagsForActiveAndArchivedCustomers() {
|
||||
var row=new HashMap<String,Object>(Map.of("cs_id",1,"cmp_archived",false,"cmp_visible",true,"cmp_authenticated",true));
|
||||
assertThat(LegacyMapping.customer(row).active).isTrue();
|
||||
row.put("cmp_archived",true);
|
||||
assertThat(LegacyMapping.customer(row).active).isFalse();
|
||||
row.put("cmp_archived",false);row.put("cmp_visible",false);
|
||||
assertThat(LegacyMapping.customer(row).active).isFalse();
|
||||
row.put("cmp_visible",true);row.put("cmp_authenticated",false);
|
||||
assertThat(LegacyMapping.customer(row).active).isFalse();
|
||||
}
|
||||
@Test void mapsBooleanCompanyFlagsAndAvailabilityForCouriers() {
|
||||
var row=new HashMap<String,Object>(Map.of("cr_id",1,"cmp_archived",false,"cmp_visible",true,"cmp_authenticated",true,"cr_available",true));
|
||||
var courier=LegacyMapping.courier(row);
|
||||
assertThat(courier.active).isTrue();
|
||||
assertThat(courier.status).isEqualTo(CourierStatus.AVAILABLE);
|
||||
row.put("cr_available",false);
|
||||
assertThat(LegacyMapping.courier(row).status).isEqualTo(CourierStatus.OFFLINE);
|
||||
row.put("active_jobs",1);
|
||||
assertThat(LegacyMapping.courier(row).status).isEqualTo(CourierStatus.BUSY);
|
||||
row.put("cmp_archived",true);
|
||||
courier=LegacyMapping.courier(row);
|
||||
assertThat(courier.active).isFalse();
|
||||
assertThat(courier.status).isEqualTo(CourierStatus.OFFLINE);
|
||||
row.put("cmp_archived",false);row.put("cmp_visible",false);
|
||||
assertThat(LegacyMapping.courier(row).active).isFalse();
|
||||
row.put("cmp_visible",true);row.put("cmp_authenticated",false);
|
||||
assertThat(LegacyMapping.courier(row).active).isFalse();
|
||||
}
|
||||
@Test void preservesNumericStatusCancellationAndWarehouseDirectionValues() {
|
||||
var row=new HashMap<String,Object>(Map.of("jb_id",1,"jb_status",2,"jb_storno",3));
|
||||
var order=LegacyMapping.order(row);
|
||||
assertThat(order.legacyStatus).isEqualTo(2);
|
||||
assertThat(order.legacyStorno).isEqualTo(3);
|
||||
assertThat(order.cancelMode).isEqualTo(CancelMode.CHARGEABLE);
|
||||
assertThat(order.isCreditNote).isTrue();
|
||||
row.put("jb_storno",4);
|
||||
order=LegacyMapping.order(row);
|
||||
assertThat(order.legacyStatus).isEqualTo(2);
|
||||
assertThat(order.legacyStorno).isEqualTo(4);
|
||||
assertThat(order.cancelMode).isEqualTo(CancelMode.FREE);
|
||||
assertThat(order.isCreditNote).isTrue();
|
||||
row.put("jb_storno",0);row.put("jb_status",9);
|
||||
assertThat(LegacyMapping.order(row).legacyStatus).isEqualTo(9);
|
||||
assertThat(LegacyMapping.order(row).status).isEqualTo(OrderStatus.DISPOSITION);
|
||||
assertThat(LegacyMapping.stop(Map.of("tr_id",1,"tr_sort",2,"tr_ware_from_to",3)).type).isEqualTo(StopType.BOTH);
|
||||
}
|
||||
@Test void mapsLegacyStatusesWithoutLosingAutorankingOrCreditNote() {
|
||||
var row=new HashMap<String,Object>(Map.of("jb_id",12,"jb_status",8,"jb_storno",0,"jb_totalprice",new BigDecimal("119.42"),"jb_subtotalprice",new BigDecimal("110.00"),"app_customer_id",15,"app_payer_customer_id",19,"jb_service",5));
|
||||
var order=LegacyMapping.order(row);
|
||||
assertThat(order.status).isEqualTo(OrderStatus.DISPOSITION);
|
||||
assertThat(order.legacyStatusLabel).isEqualTo("Autoranking");
|
||||
assertThat(order.netPrice).isEqualByComparingTo("119.42");
|
||||
assertThat(order.customerId).isEqualTo(15L);
|
||||
assertThat(order.payerCustomerId).isEqualTo(19L);
|
||||
assertThat(order.legacyService).isEqualTo(5);
|
||||
row.put("jb_storno",3);row.put("jb_status",2);
|
||||
order=LegacyMapping.order(row);
|
||||
assertThat(order.isCreditNote).isTrue();
|
||||
assertThat(order.cancelMode).isEqualTo(CancelMode.CHARGEABLE);
|
||||
assertThat(order.legacyStatusLabel).contains("Storno-Gutschrift");
|
||||
}
|
||||
@Test void respectsExplicitWarehouseDirectionBeforeStationPosition() {
|
||||
var row=new HashMap<String,Object>(Map.of("tr_id",1,"tr_sort",3,"tr_ware_from_to",1));
|
||||
assertThat(LegacyMapping.stop(row).type).isEqualTo(StopType.PICKUP);
|
||||
row.put("tr_ware_from_to",3);
|
||||
assertThat(LegacyMapping.stop(row).type).isEqualTo(StopType.BOTH);
|
||||
row.put("tr_ware_from_to",2);row.put("tr_sort",1);
|
||||
assertThat(LegacyMapping.stop(row).type).isEqualTo(StopType.DELIVERY);
|
||||
row.put("tr_ware_from_to",0);
|
||||
assertThat(LegacyMapping.stop(row).type).isEqualTo(StopType.PICKUP);
|
||||
}
|
||||
@Test void missingValuesAndMysqlZeroDatesRemainUnknown() {
|
||||
var order=LegacyMapping.order(Map.of("jb_id",7,"jb_ordertime","0000-00-00 00:00:00","jb_modify","20261001123159"));
|
||||
assertThat(order.pickupAt).isNull();
|
||||
assertThat(order.netPrice).isNull();
|
||||
assertThat(order.distanceKm).isNull();
|
||||
assertThat(order.packages).isNull();
|
||||
assertThat(order.updatedAt.toString()).isEqualTo("2026-10-01T12:31:59");
|
||||
}
|
||||
@Test void csvEscapesFormulaAndQuotes() {
|
||||
assertThat(ApiController.csvCell("=HYPERLINK(\"test\")")).isEqualTo("\"'=HYPERLINK(\"\"test\"\")\"");
|
||||
assertThat(ApiController.csvCell(null)).isEqualTo("\"\"");
|
||||
}
|
||||
@Test void doesNotPublishCredentialsFromLegacyUrl() {
|
||||
assertThatThrownBy(()->ApiController.safeLegacyUrl("https://user:secret@example.org/votian")).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(()->ApiController.safeLegacyUrl("javascript:alert(1)")).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThat(ApiController.safeLegacyUrl("https://example.org/votian/")).isEqualTo("https://example.org/votian");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import java.util.function.Supplier;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.time.LocalDate;
|
||||
import java.util.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class LegacyReadServiceTest {
|
||||
final DatabaseService database=mock(DatabaseService.class);
|
||||
final LegacyReadService service=new LegacyReadService(database,500);
|
||||
final LegacyUser allowed=new LegacyUser(12,13,7,"disponent","111111111111");
|
||||
@BeforeEach void configureTransactions() {
|
||||
when(database.readTransaction(any())).thenAnswer(call -> ((Supplier<?>)call.getArgument(0)).get());
|
||||
}
|
||||
@Test void refusesUnpermittedDataWithoutTouchingDatabase() {
|
||||
var denied=new LegacyUser(1,2,7,"restricted","0000000000");
|
||||
assertThatThrownBy(()->service.orders(denied,null,null,null,null,null,null)).isInstanceOf(AccessDeniedException.class);
|
||||
assertThatThrownBy(()->service.customers(denied,null)).isInstanceOf(AccessDeniedException.class);
|
||||
assertThatThrownBy(()->service.couriers(denied,null)).isInstanceOf(AccessDeniedException.class);
|
||||
verifyNoInteractions(database);
|
||||
}
|
||||
@Test void parameterizesScopeAndSearchAndLimitsOnlyAfterSqlFilters() {
|
||||
when(database.queryForObject(anyString(),eq(Long.class),any(Object[].class))).thenReturn(0L);
|
||||
when(database.queryForList(anyString(),any(Object[].class))).thenReturn(List.of());
|
||||
service.orders(allowed,"' OR 1=1 --",null,23L,42L,LocalDate.of(2026,10,1),LocalDate.of(2026,10,5));
|
||||
ArgumentCaptor<String> sql=ArgumentCaptor.forClass(String.class);
|
||||
ArgumentCaptor<Object[]> args=ArgumentCaptor.forClass(Object[].class);
|
||||
verify(database).queryForList(sql.capture(),args.capture());
|
||||
assertThat(sql.getValue()).contains("jb.hq_id = ?","cc.cs_id = ?","jb.cr_id = ?","LIMIT 500").doesNotContain("OR 1=1");
|
||||
assertThat(args.getValue()[0]).isEqualTo(7L);
|
||||
assertThat(Arrays.asList(args.getValue())).contains("%' OR 1=1 --%");
|
||||
}
|
||||
@Test void singleOrderCannotEscapeHeadquarters() {
|
||||
when(database.queryForList(anyString(),any(Object[].class))).thenReturn(List.of());
|
||||
assertThatThrownBy(()->service.order(allowed,999)).isInstanceOf(ResponseStatusException.class);
|
||||
verify(database).queryForList(contains("WHERE jb.jb_id=? AND jb.hq_id=?"),eq(999L),eq(7L));
|
||||
}
|
||||
@Test void customerAndCourierQueriesRemainScopedToHomeBranch() {
|
||||
when(database.queryForList(anyString(),any(Object[].class))).thenReturn(List.of());
|
||||
service.customers(allowed,null);service.couriers(allowed,null);
|
||||
verify(database).queryForList(contains("WHERE cs.hq_id=?"),eq(7L));
|
||||
verify(database).queryForList(contains("WHERE u.hq_id=? AND cr.hq_id=u.hq_id"),eq(7L));
|
||||
}
|
||||
@Test void invalidDateRangeNeverHitsDatabase() {
|
||||
assertThatThrownBy(()->service.orders(allowed,null,null,null,null,LocalDate.of(2026,10,5),LocalDate.of(2026,10,1))).isInstanceOf(ResponseStatusException.class);
|
||||
verifyNoInteractions(database);
|
||||
}
|
||||
@Test void invalidSearchIsRejectedBeforeOpeningATransaction() {
|
||||
assertThatThrownBy(()->service.orders(allowed,"x".repeat(201),null,null,null,null,null)).isInstanceOf(ResponseStatusException.class);
|
||||
verifyNoInteractions(database);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package de.stadtbote.votian.database;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class DatabaseDiagnosticsTest {
|
||||
private final DatabaseService database = mock(DatabaseService.class);
|
||||
private final DatabaseDiagnostics diagnostics = new DatabaseDiagnostics(database);
|
||||
|
||||
@Test void rejectedConnectionDoesNotReadMetadata() {
|
||||
when(database.checkConnection()).thenReturn(new DatabaseService.ConnectionCheck(
|
||||
false, "AUTHENTICATION_FAILED", "Anmeldung abgewiesen."));
|
||||
|
||||
DatabaseDiagnostics.Result result = diagnostics.check();
|
||||
|
||||
assertThat(result.exitCode()).isEqualTo(1);
|
||||
assertThat(result.details()).containsEntry("connected", false).containsEntry("errorCode", 1045)
|
||||
.containsEntry("applicationRowsRead", 0).containsEntry("writes", 0);
|
||||
verify(database).checkConnection();
|
||||
verifyNoMoreInteractions(database);
|
||||
}
|
||||
|
||||
@Test void reportsMissingColumnsWithoutQueryingApplicationTables() {
|
||||
when(database.checkConnection()).thenReturn(new DatabaseService.ConnectionCheck(true, "CONNECTED", "Verbunden."));
|
||||
when(database.queryForObject("SELECT VERSION()", String.class)).thenReturn("test-server");
|
||||
String metadataSql = "SELECT TABLE_NAME AS table_name,COLUMN_NAME AS column_name "
|
||||
+ "FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE()";
|
||||
when(database.queryForList(metadataSql)).thenReturn(List.of(
|
||||
Map.of("table_name", "headquarters", "column_name", "hq_id"),
|
||||
Map.of("table_name", "job", "column_name", "jb_id")));
|
||||
|
||||
DatabaseDiagnostics.Result result = diagnostics.check();
|
||||
|
||||
assertThat(result.exitCode()).isEqualTo(1);
|
||||
assertThat(result.details()).containsEntry("connected", true).containsEntry("serverVersion", "test-server")
|
||||
.containsEntry("checkedTables", 13).containsEntry("applicationRowsRead", 0).containsEntry("writes", 0);
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, List<String>> missing = (Map<String, List<String>>) result.details().get("missingColumns");
|
||||
assertThat(missing).doesNotContainKey("headquarters");
|
||||
assertThat(missing.get("job")).contains("jb_totalprice", "csc_id_payer").doesNotContain("jb_id");
|
||||
verify(database).checkConnection();
|
||||
verify(database).queryForObject("SELECT VERSION()", String.class);
|
||||
verify(database).queryForList(metadataSql);
|
||||
verifyNoMoreInteractions(database);
|
||||
}
|
||||
|
||||
@Test void neverReturnsJdbcExceptionDetails() {
|
||||
when(database.checkConnection()).thenReturn(new DatabaseService.ConnectionCheck(true, "CONNECTED", "Verbunden."));
|
||||
when(database.queryForObject("SELECT VERSION()", String.class))
|
||||
.thenThrow(new IllegalStateException("jdbc:vendor://private-host password=secret"));
|
||||
|
||||
DatabaseDiagnostics.Result result = diagnostics.check();
|
||||
|
||||
assertThat(result.exitCode()).isEqualTo(1);
|
||||
assertThat(result.details()).containsEntry("connected", false).containsEntry("reason", "UNAVAILABLE")
|
||||
.containsEntry("writes", 0);
|
||||
assertThat(result.details().toString()).doesNotContain("private-host", "secret", "jdbc:");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package de.stadtbote.votian.database;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.dao.DataAccessResourceFailureException;
|
||||
import org.springframework.jdbc.CannotGetJdbcConnectionException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
import org.springframework.transaction.CannotCreateTransactionException;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.TransactionDefinition;
|
||||
import org.springframework.transaction.support.SimpleTransactionStatus;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Connection;
|
||||
import java.sql.Statement;
|
||||
import javax.sql.DataSource;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class DatabaseServiceTest {
|
||||
final JdbcTemplate jdbc=mock(JdbcTemplate.class);
|
||||
final PlatformTransactionManager transactions=mock(PlatformTransactionManager.class);
|
||||
final SimpleTransactionStatus transaction=new SimpleTransactionStatus();
|
||||
final DatabaseService database=new DatabaseService(jdbc,transactions);
|
||||
|
||||
DatabaseServiceTest() {
|
||||
when(transactions.getTransaction(any())).thenReturn(transaction);
|
||||
clearInvocations(transactions);
|
||||
}
|
||||
|
||||
@Test void enforcesReadOnlyOnTheServerBeforeExecutingQueries() throws Exception {
|
||||
var source=mock(DataSource.class);
|
||||
var connection=mock(Connection.class);
|
||||
var statement=mock(Statement.class);
|
||||
when(source.getConnection()).thenReturn(connection);
|
||||
when(connection.getAutoCommit()).thenReturn(true);
|
||||
when(connection.createStatement()).thenReturn(statement);
|
||||
var manager=new DataSourceTransactionManager(source);
|
||||
var service=new DatabaseService(jdbc,manager);
|
||||
when(jdbc.queryForObject("SELECT 1",Integer.class,new Object[0])).thenReturn(1);
|
||||
|
||||
assertThat(service.queryForObject("SELECT 1",Integer.class)).isEqualTo(1);
|
||||
assertThat(manager.isEnforceReadOnly()).isTrue();
|
||||
var order=inOrder(statement,jdbc,connection);
|
||||
order.verify(statement).executeUpdate("SET TRANSACTION READ ONLY");
|
||||
order.verify(jdbc).queryForObject("SELECT 1",Integer.class,new Object[0]);
|
||||
order.verify(connection).commit();
|
||||
verify(connection).close();
|
||||
}
|
||||
|
||||
@Test void bindsValuesWithoutInsertingThemIntoSqlAndUsesReadOnlyTransactions() {
|
||||
String sql="SELECT jb_id FROM job WHERE jb_commission_no=? AND jb_storno=?";
|
||||
Object[] values={"'; DELETE FROM job; --",null};
|
||||
var rows=List.of(Map.<String,Object>of("jb_id",17));
|
||||
when(jdbc.queryForList(sql,values)).thenReturn(rows);
|
||||
assertThat(database.queryForList(sql,values)).isSameAs(rows);
|
||||
verify(jdbc).queryForList(sql,values);
|
||||
var definition=ArgumentCaptor.forClass(TransactionDefinition.class);
|
||||
verify(transactions).getTransaction(definition.capture());
|
||||
assertThat(definition.getValue().isReadOnly()).isTrue();
|
||||
verify(transactions).commit(transaction);
|
||||
}
|
||||
|
||||
@Test void typedQueriesPreserveNullResultsAndBoundParameters() {
|
||||
when(jdbc.queryForObject("SELECT MAX(jb_id) FROM job WHERE hq_id=?",Long.class,7L)).thenReturn(null);
|
||||
assertThat(database.queryForObject("SELECT MAX(jb_id) FROM job WHERE hq_id=?",Long.class,7L)).isNull();
|
||||
verify(jdbc).queryForObject("SELECT MAX(jb_id) FROM job WHERE hq_id=?",Long.class,7L);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings={"UPDATE job SET jb_status=2", "INSERT INTO job VALUES (?)", "DELETE FROM job", "CREATE TABLE test(id INT)", "CALL procedure_name()", "SET autocommit=1", "SELECT 1; DELETE FROM job", "SELECT 1 INTO OUTFILE '/tmp/data'", "SELECT jb_id FROM job FOR UPDATE", "/* read */ DELETE FROM job", "SELECT 1 -- comment"})
|
||||
void rejectsNonReadStatementsBeforeJdbcOrTransactions(String sql) {
|
||||
assertThatIllegalArgumentException().isThrownBy(()->database.queryForList(sql));
|
||||
assertThatIllegalArgumentException().isThrownBy(()->database.queryForObject(sql,Integer.class));
|
||||
verifyNoInteractions(jdbc,transactions);
|
||||
}
|
||||
|
||||
@Test void writeEntryPointIsClosedBeforeOpeningAnyConnection() {
|
||||
assertThatThrownBy(()->database.update("UPDATE job SET jb_status=? WHERE jb_id=?",2,17))
|
||||
.isInstanceOf(DatabaseWritesDisabledException.class);
|
||||
verifyNoInteractions(jdbc,transactions);
|
||||
}
|
||||
|
||||
@Test void failedQueriesRollbackAndDoNotReturnAnEmptyReplacement() {
|
||||
var failure=new DataAccessResourceFailureException("private database detail");
|
||||
when(jdbc.queryForList("SELECT jb_id FROM job",new Object[0])).thenThrow(failure);
|
||||
assertThatThrownBy(()->database.queryForList("SELECT jb_id FROM job")).isSameAs(failure);
|
||||
verify(transactions).rollback(transaction);
|
||||
verify(transactions,never()).commit(any());
|
||||
}
|
||||
|
||||
@Test void transactionConnectionFailuresUseTheSameDatabaseErrorContract() {
|
||||
when(transactions.getTransaction(any())).thenThrow(new CannotCreateTransactionException("private details"));
|
||||
assertThatThrownBy(()->database.queryForObject("SELECT 1",Integer.class))
|
||||
.isInstanceOf(DataAccessResourceFailureException.class)
|
||||
.hasMessage(DatabaseService.DATABASE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
@Test void connectionCheckRecognizesLoginDenialInsideTransactionFailure() {
|
||||
when(transactions.getTransaction(any())).thenThrow(new CannotCreateTransactionException("private detail",new SQLException("password must not be exposed","28000",1045)));
|
||||
var result=database.checkConnection();
|
||||
assertThat(result.connected()).isFalse();
|
||||
assertThat(result.reason()).isEqualTo("AUTHENTICATION_FAILED");
|
||||
assertThat(result.message()).doesNotContain("password must not be exposed");
|
||||
}
|
||||
|
||||
@Test void connectionCheckDistinguishesMissingDatabaseRightsAndNetworkFailure() {
|
||||
when(jdbc.queryForObject("SELECT 1",Integer.class,new Object[0]))
|
||||
.thenThrow(new CannotGetJdbcConnectionException("private",new SQLException("private","42000",1044)))
|
||||
.thenThrow(new CannotGetJdbcConnectionException("private"));
|
||||
assertThat(database.checkConnection().reason()).isEqualTo("DATABASE_ACCESS_DENIED");
|
||||
assertThat(database.checkConnection().reason()).isEqualTo("UNAVAILABLE");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
mock-maker-subclass
|
||||
Reference in new issue
Block a user