From 26a253abf63809ca55846f59d1305cbd6411734d Mon Sep 17 00:00:00 2001 From: naumueller Date: Fri, 6 Mar 2026 16:24:43 +0100 Subject: [PATCH] AppConfigValidator and AppConfigValidatorTest added and integrated into ApplicationInitializer --- .../bootstrap/ApplicationInitializer.java | 3 + .../vassistent/util/AppConfigValidator.java | 591 ++++++++++++++++++ .../util/AppConfigValidatorTest.java | 155 +++++ 3 files changed, 749 insertions(+) create mode 100644 src/main/java/vassistent/util/AppConfigValidator.java create mode 100644 src/test/java/vassistent/util/AppConfigValidatorTest.java diff --git a/src/main/java/vassistent/bootstrap/ApplicationInitializer.java b/src/main/java/vassistent/bootstrap/ApplicationInitializer.java index 12b03a5..37e2eeb 100644 --- a/src/main/java/vassistent/bootstrap/ApplicationInitializer.java +++ b/src/main/java/vassistent/bootstrap/ApplicationInitializer.java @@ -3,6 +3,7 @@ package vassistent.bootstrap; import com.formdev.flatlaf.FlatDarkLaf; import vassistent.model.AppState; import vassistent.service.*; +import vassistent.util.AppConfigValidator; import vassistent.util.ConfigLoader; import javax.swing.*; @@ -25,6 +26,8 @@ public class ApplicationInitializer { */ public static ApplicationContext initialize() { + AppConfigValidator.validate(config); + ApplicationContext context = new ApplicationContext(); // ===== Model State ===== diff --git a/src/main/java/vassistent/util/AppConfigValidator.java b/src/main/java/vassistent/util/AppConfigValidator.java new file mode 100644 index 0000000..aaa1b5b --- /dev/null +++ b/src/main/java/vassistent/util/AppConfigValidator.java @@ -0,0 +1,591 @@ +package vassistent.util; + +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Properties; +import java.util.Set; + +/** + * Validates startup configuration and fails fast on invalid values. + */ +public final class AppConfigValidator { + + private static final Set SUPPORTED_MQTT_SCHEMES = + Set.of("tcp", "ssl", "ws", "wss"); + + /** + * Utility class constructor. + */ + private AppConfigValidator() {} + + /** + * Validates all required startup configuration values. + * + * @param config loaded application properties + */ + public static void validate(Properties config) { + Objects.requireNonNull(config, "config must not be null"); + + List errors = new ArrayList<>(); + + validateRequiredMqttSettings(config, errors); + validateCoreUiAndOutputSettings(config, errors); + + boolean mqttSimulatorEnabled = + readBoolean(config, errors, "mqtt_sim.enabled", false); + boolean unrealEnabled = + readBoolean(config, errors, "unreal.enabled", false); + + if (mqttSimulatorEnabled) { + validateMqttSimulatorSettings(config, errors); + } + + if (unrealEnabled) { + validateUnrealSettings(config, errors); + } + + if (!errors.isEmpty()) { + throw new IllegalStateException(buildValidationMessage(errors)); + } + } + + /** + * Validates mandatory MQTT runtime keys. + * + * @param config loaded properties + * @param errors collected validation errors + */ + private static void validateRequiredMqttSettings( + Properties config, + List errors + ) { + validateRequiredNonBlank(config, errors, "mqtt.topic"); + validateRequiredNonBlank(config, errors, "mqtt.client.id"); + + String brokerUrl = + validateRequiredNonBlank(config, errors, "mqtt.broker.url"); + if (brokerUrl == null) { + return; + } + + try { + URI uri = URI.create(brokerUrl); + String scheme = uri.getScheme(); + if (scheme == null + || !SUPPORTED_MQTT_SCHEMES.contains( + scheme.toLowerCase(Locale.ROOT))) { + errors.add( + "Property 'mqtt.broker.url' must use one of " + + SUPPORTED_MQTT_SCHEMES + ": " + + brokerUrl + ); + } + + if (uri.getHost() == null || uri.getHost().isBlank()) { + errors.add( + "Property 'mqtt.broker.url' must include a host: " + + brokerUrl + ); + } + + int port = uri.getPort(); + if (port != -1 && (port < 1 || port > 65535)) { + errors.add( + "Property 'mqtt.broker.url' contains invalid port " + + port + ": " + brokerUrl + ); + } + } catch (Exception e) { + errors.add( + "Property 'mqtt.broker.url' is not a valid URI: " + brokerUrl + ); + } + } + + /** + * Validates UI and animation output settings. + * + * @param config loaded properties + * @param errors collected validation errors + */ + private static void validateCoreUiAndOutputSettings( + Properties config, + List errors + ) { + String streamingUrl = + validateRequiredNonBlank(config, errors, "streaming.url"); + if (streamingUrl != null) { + try { + URI uri = URI.create(streamingUrl); + String scheme = uri.getScheme(); + if (scheme == null + || (!"http".equalsIgnoreCase(scheme) + && !"https".equalsIgnoreCase(scheme))) { + errors.add( + "Property 'streaming.url' must start with http:// or https://: " + + streamingUrl + ); + } + } catch (Exception e) { + errors.add( + "Property 'streaming.url' is not a valid URI: " + streamingUrl + ); + } + } + + validateRequiredNonBlank(config, errors, "animation.output.path"); + } + + /** + * Validates simulator settings that are required only when simulator startup is enabled. + * + * @param config loaded properties + * @param errors collected validation errors + */ + private static void validateMqttSimulatorSettings( + Properties config, + List errors + ) { + validateExistingFile(config, errors, "python.path"); + validateExistingFile(config, errors, "mqtt_sim.script"); + validateRequiredNonBlank(config, errors, "mqtt_sim.broker"); + validateRequiredIntegerInRange(config, errors, "mqtt_sim.port", 1, 65535); + validateOptionalIntegerInRange(config, errors, "mqtt_sim.qos", 0, 2); + validateOptionalPositiveDouble(config, errors, "mqtt_sim.interval_seconds"); + } + + /** + * Validates Unreal-related settings that are required only when Unreal startup is enabled. + * + * @param config loaded properties + * @param errors collected validation errors + */ + private static void validateUnrealSettings( + Properties config, + List errors + ) { + validateExistingFile(config, errors, "unreal.executable"); + validateExistingFile(config, errors, "unreal.signalling_server.script"); + validateExistingFile(config, errors, "unreal.target.executable"); + validateExistingDirectory(config, errors, "unreal.target.working_dir"); + validateOptionalExistingDirectory(config, errors, "unreal.pid.dir"); + validateOptionalNonNegativeInteger(config, errors, "unreal.startup.delay.seconds"); + validateOptionalPidFileParent(config, errors, "unreal.pid.file"); + validateOptionalPidFileParent(config, errors, "unreal.signalling.pid.file"); + } + + /** + * Validates that a required property exists and is not blank. + * + * @param config loaded properties + * @param errors collected validation errors + * @param key property key + * @return normalized value or {@code null} when missing/blank + */ + private static String validateRequiredNonBlank( + Properties config, + List errors, + String key + ) { + String value = normalizeConfigValue(config.getProperty(key)); + if (value == null || value.isBlank()) { + errors.add("Missing required property '" + key + "'."); + return null; + } + return value; + } + + /** + * Validates that a property points to an existing regular file. + * + * @param config loaded properties + * @param errors collected validation errors + * @param key property key + */ + private static void validateExistingFile( + Properties config, + List errors, + String key + ) { + String rawValue = config.getProperty(key); + String normalized = validateRequiredNonBlank(config, errors, key); + if (normalized == null) { + return; + } + + Path path = parsePath(rawValue, normalized, key, errors); + if (path == null) { + return; + } + + if (!Files.isRegularFile(path)) { + errors.add( + "Property '" + key + "' must reference an existing file: " + normalized + ); + } + } + + /** + * Validates that a property points to an existing directory. + * + * @param config loaded properties + * @param errors collected validation errors + * @param key property key + */ + private static void validateExistingDirectory( + Properties config, + List errors, + String key + ) { + String rawValue = config.getProperty(key); + String normalized = validateRequiredNonBlank(config, errors, key); + if (normalized == null) { + return; + } + + Path path = parsePath(rawValue, normalized, key, errors); + if (path == null) { + return; + } + + if (!Files.isDirectory(path)) { + errors.add( + "Property '" + key + "' must reference an existing directory: " + normalized + ); + } + } + + /** + * Validates an optional directory property when a value is provided. + * + * @param config loaded properties + * @param errors collected validation errors + * @param key property key + */ + private static void validateOptionalExistingDirectory( + Properties config, + List errors, + String key + ) { + String rawValue = config.getProperty(key); + String normalized = normalizeConfigValue(rawValue); + if (normalized == null || normalized.isBlank()) { + return; + } + + Path path = parsePath(rawValue, normalized, key, errors); + if (path == null) { + return; + } + + if (!Files.isDirectory(path)) { + errors.add( + "Property '" + key + "' must reference an existing directory: " + normalized + ); + } + } + + /** + * Validates an optional integer property with a minimum/maximum range. + * + * @param config loaded properties + * @param errors collected validation errors + * @param key property key + * @param min minimum valid value + * @param max maximum valid value + */ + private static void validateOptionalIntegerInRange( + Properties config, + List errors, + String key, + int min, + int max + ) { + String value = normalizeConfigValue(config.getProperty(key)); + if (value == null || value.isBlank()) { + return; + } + + try { + int parsed = Integer.parseInt(value); + if (parsed < min || parsed > max) { + errors.add( + "Property '" + key + "' must be between " + + min + " and " + max + ", but was " + parsed + "." + ); + } + } catch (NumberFormatException e) { + errors.add( + "Property '" + key + "' must be an integer, but was '" + value + "'." + ); + } + } + + /** + * Validates a required integer property with a minimum/maximum range. + * + * @param config loaded properties + * @param errors collected validation errors + * @param key property key + * @param min minimum valid value + * @param max maximum valid value + */ + private static void validateRequiredIntegerInRange( + Properties config, + List errors, + String key, + int min, + int max + ) { + String value = validateRequiredNonBlank(config, errors, key); + if (value == null) { + return; + } + + try { + int parsed = Integer.parseInt(value); + if (parsed < min || parsed > max) { + errors.add( + "Property '" + key + "' must be between " + + min + " and " + max + ", but was " + parsed + "." + ); + } + } catch (NumberFormatException e) { + errors.add( + "Property '" + key + "' must be an integer, but was '" + value + "'." + ); + } + } + + /** + * Validates an optional numeric property that must be greater than zero. + * + * @param config loaded properties + * @param errors collected validation errors + * @param key property key + */ + private static void validateOptionalPositiveDouble( + Properties config, + List errors, + String key + ) { + String value = normalizeConfigValue(config.getProperty(key)); + if (value == null || value.isBlank()) { + return; + } + + try { + double parsed = Double.parseDouble(value); + if (parsed <= 0.0) { + errors.add( + "Property '" + key + "' must be greater than 0, but was " + parsed + "." + ); + } + } catch (NumberFormatException e) { + errors.add( + "Property '" + key + "' must be numeric, but was '" + value + "'." + ); + } + } + + /** + * Validates an optional integer that must be non-negative. + * + * @param config loaded properties + * @param errors collected validation errors + * @param key property key + */ + private static void validateOptionalNonNegativeInteger( + Properties config, + List errors, + String key + ) { + String value = normalizeConfigValue(config.getProperty(key)); + if (value == null || value.isBlank()) { + return; + } + + try { + int parsed = Integer.parseInt(value); + if (parsed < 0) { + errors.add( + "Property '" + key + "' must be >= 0, but was " + parsed + "." + ); + } + } catch (NumberFormatException e) { + errors.add( + "Property '" + key + "' must be an integer, but was '" + value + "'." + ); + } + } + + /** + * Validates that an optional PID file path has an existing parent directory. + * + * @param config loaded properties + * @param errors collected validation errors + * @param key property key + */ + private static void validateOptionalPidFileParent( + Properties config, + List errors, + String key + ) { + String rawValue = config.getProperty(key); + String normalized = normalizeConfigValue(rawValue); + + if (normalized == null || normalized.isBlank()) { + return; + } + + Path path = parsePath(rawValue, normalized, key, errors); + if (path == null) { + return; + } + + Path parent = path.getParent(); + if (parent != null && !Files.isDirectory(parent)) { + errors.add( + "Parent directory for '" + key + "' does not exist: " + parent + ); + } + } + + /** + * Validates a boolean property using strict true/false values. + * + * @param config loaded properties + * @param errors collected validation errors + * @param key property key + * @param defaultValue fallback when value is absent/blank + * @return parsed boolean value or default value + */ + private static boolean readBoolean( + Properties config, + List errors, + String key, + boolean defaultValue + ) { + String value = normalizeConfigValue(config.getProperty(key)); + if (value == null || value.isBlank()) { + return defaultValue; + } + + if ("true".equalsIgnoreCase(value)) { + return true; + } + + if ("false".equalsIgnoreCase(value)) { + return false; + } + + errors.add( + "Property '" + key + "' must be 'true' or 'false', but was '" + value + "'." + ); + return defaultValue; + } + + /** + * Parses a path value and reports invalid quoting/path syntax. + * + * @param rawValue raw property value + * @param normalized normalized property value + * @param key property key + * @param errors collected validation errors + * @return parsed path or {@code null} when invalid + */ + private static Path parsePath( + String rawValue, + String normalized, + String key, + List errors + ) { + if (hasMismatchedWrappingQuote(rawValue)) { + errors.add( + "Property '" + key + "' has mismatched wrapping quotes: " + rawValue + ); + } + + try { + return Path.of(normalized); + } catch (InvalidPathException e) { + errors.add( + "Property '" + key + "' contains an invalid path: " + normalized + ); + return null; + } + } + + /** + * Detects mismatched leading/trailing single or double quote wrappers. + * + * @param rawValue raw property value + * @return {@code true} when wrapper quotes are mismatched + */ + private static boolean hasMismatchedWrappingQuote(String rawValue) { + if (rawValue == null) { + return false; + } + + String trimmed = rawValue.trim(); + if (trimmed.isEmpty()) { + return false; + } + + boolean startsDouble = trimmed.startsWith("\""); + boolean endsDouble = trimmed.endsWith("\""); + + boolean startsSingle = trimmed.startsWith("'"); + boolean endsSingle = trimmed.endsWith("'"); + + return startsDouble != endsDouble || startsSingle != endsSingle; + } + + /** + * Trims config values and removes matching single/double quote wrappers. + * + * @param value raw config value + * @return normalized value or {@code null} + */ + private static String normalizeConfigValue(String value) { + if (value == null) { + return null; + } + + String trimmed = value.trim(); + + if ((trimmed.startsWith("\"") && trimmed.endsWith("\"")) + || (trimmed.startsWith("'") && trimmed.endsWith("'"))) { + return trimmed.substring(1, trimmed.length() - 1); + } + + return trimmed; + } + + /** + * Builds a single human-readable validation exception message. + * + * @param errors collected validation errors + * @return formatted message + */ + private static String buildValidationMessage(List errors) { + StringBuilder message = new StringBuilder( + "Configuration validation failed (" + errors.size() + " issue(s)):" + ); + + for (int i = 0; i < errors.size(); i++) { + message.append(System.lineSeparator()) + .append(i + 1) + .append(". ") + .append(errors.get(i)); + } + + return message.toString(); + } +} diff --git a/src/test/java/vassistent/util/AppConfigValidatorTest.java b/src/test/java/vassistent/util/AppConfigValidatorTest.java new file mode 100644 index 0000000..ea4ac53 --- /dev/null +++ b/src/test/java/vassistent/util/AppConfigValidatorTest.java @@ -0,0 +1,155 @@ +package vassistent.util; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Properties; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for {@link AppConfigValidator}. + */ +class AppConfigValidatorTest { + + @TempDir + Path tempDir; + + /** + * Verifies that a minimal core configuration passes validation. + */ + @Test + void validate_withCoreSettingsOnly_doesNotThrow() { + Properties config = baseConfig(); + + assertDoesNotThrow(() -> AppConfigValidator.validate(config)); + } + + /** + * Verifies strict boolean validation for feature toggles. + */ + @Test + void validate_withInvalidBooleanToggle_throws() { + Properties config = baseConfig(); + config.setProperty("mqtt_sim.enabled", "yes"); + + IllegalStateException exception = + assertThrows( + IllegalStateException.class, + () -> AppConfigValidator.validate(config) + ); + + assertTrue(exception.getMessage().contains("mqtt_sim.enabled")); + } + + /** + * Verifies simulator-specific numeric validation when simulator is enabled. + * + * @throws Exception if test files cannot be created + */ + @Test + void validate_withMqttSimulatorInvalidPort_throws() throws Exception { + Properties config = baseConfig(); + Path pythonExe = Files.createFile(tempDir.resolve("python.exe")); + Path simulatorScript = Files.createFile(tempDir.resolve("mqtt_simulator.py")); + + config.setProperty("mqtt_sim.enabled", "true"); + config.setProperty("python.path", pythonExe.toString()); + config.setProperty("mqtt_sim.script", simulatorScript.toString()); + config.setProperty("mqtt_sim.broker", "localhost"); + config.setProperty("mqtt_sim.port", "99999"); + + IllegalStateException exception = + assertThrows( + IllegalStateException.class, + () -> AppConfigValidator.validate(config) + ); + + assertTrue(exception.getMessage().contains("mqtt_sim.port")); + } + + /** + * Verifies Unreal validation detects paths with mismatched quote wrappers. + * + * @throws Exception if test files cannot be created + */ + @Test + void validate_withUnrealSignallingPathTrailingQuote_throws() throws Exception { + Properties config = baseConfig(); + Path launcherScript = Files.createFile(tempDir.resolve("start_avatar.ps1")); + Path signallingScript = Files.createFile(tempDir.resolve("start_with_stun.bat")); + Path unrealExe = Files.createFile(tempDir.resolve("Prototyp1.exe")); + Path unrealWorkDir = Files.createDirectory(tempDir.resolve("Windows")); + Path pidDir = Files.createDirectory(tempDir.resolve("pids")); + + config.setProperty("unreal.enabled", "true"); + config.setProperty("unreal.executable", launcherScript.toString()); + config.setProperty( + "unreal.signalling_server.script", + signallingScript.toString() + "\"" + ); + config.setProperty("unreal.target.executable", unrealExe.toString()); + config.setProperty("unreal.target.working_dir", unrealWorkDir.toString()); + config.setProperty("unreal.pid.dir", pidDir.toString()); + + IllegalStateException exception = + assertThrows( + IllegalStateException.class, + () -> AppConfigValidator.validate(config) + ); + + assertTrue(exception.getMessage().contains("unreal.signalling_server.script")); + assertTrue(exception.getMessage().toLowerCase().contains("quote")); + } + + /** + * Verifies Unreal validation accepts a complete and valid local setup. + * + * @throws Exception if test files cannot be created + */ + @Test + void validate_withUnrealEnabledAndValidPaths_doesNotThrow() throws Exception { + Properties config = baseConfig(); + Path launcherScript = Files.createFile(tempDir.resolve("start_avatar.ps1")); + Path signallingScript = Files.createFile(tempDir.resolve("start_with_stun.bat")); + Path unrealExe = Files.createFile(tempDir.resolve("Prototyp1.exe")); + Path unrealWorkDir = Files.createDirectory(tempDir.resolve("Windows")); + Path pidDir = Files.createDirectory(tempDir.resolve("pids")); + + config.setProperty("unreal.enabled", "true"); + config.setProperty("unreal.executable", launcherScript.toString()); + config.setProperty("unreal.signalling_server.script", signallingScript.toString()); + config.setProperty("unreal.target.executable", unrealExe.toString()); + config.setProperty("unreal.target.working_dir", unrealWorkDir.toString()); + config.setProperty("unreal.pid.dir", pidDir.toString()); + config.setProperty("unreal.startup.delay.seconds", "5"); + config.setProperty("unreal.pid.file", pidDir.resolve("unreal.pid").toString()); + config.setProperty( + "unreal.signalling.pid.file", + pidDir.resolve("signalling.pid").toString() + ); + + assertDoesNotThrow(() -> AppConfigValidator.validate(config)); + } + + /** + * Creates a baseline config with optional integrations disabled. + * + * @return baseline application configuration + */ + private Properties baseConfig() { + Properties config = new Properties(); + config.setProperty("mqtt.topic", "PREDICTION"); + config.setProperty("mqtt.broker.url", "tcp://localhost:1883"); + config.setProperty("mqtt.client.id", "JavaClientPublisherSubscriber"); + config.setProperty("streaming.url", "http://localhost"); + config.setProperty("animation.output.path", "data/animation.json"); + config.setProperty("mqtt_sim.enabled", "false"); + config.setProperty("unreal.enabled", "false"); + return config; + } +}