From 7101fab05c2b9187488574b1e4ffeae854108af8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jegors=20=C4=8Cemisovs?= Date: Sun, 13 Sep 2026 19:24:34 +0300 Subject: [PATCH 1/3] feat: upgrade to dicechess-bot-runtime 2.0.0 --- README.md | 17 +-- pom.xml | 4 +- .../com/fortemate/dicechess/bot/Main.java | 69 ++++++++-- .../fortemate/dicechess/bot/OnnxStrategy.java | 2 +- .../com/fortemate/dicechess/bot/Strategy.java | 14 +- .../fortemate/dicechess/bot/package-info.java | 7 +- .../dicechess/bot/OnnxStrategyTest.java | 36 ++++- .../dicechess/bot/WebhookIntegrationTest.java | 124 +++++++++++++++++- 8 files changed, 237 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index c9a5bc5..3053d63 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ This repository serves two primary roles: - **Java 25 & JDK HttpServer**: Built on modern Java 25 (LTS) with minimal dependencies and zero heavy frameworks (~64 MB RAM footprint). - **ONNX Model Evaluation**: Evaluates candidate full-turn move paths using ONNX value models (`models/baseline.onnx`) with JvmApi engine heuristic fallback. -- **Bot Runtime Integration**: Uses `lv.id.jc:dicechess-bot-runtime` for HMAC-SHA256 signature verification, webhook handshakes, and `TurnContext` processing. +- **Bot Runtime Integration**: Uses `com.fortemate:dicechess-bot-runtime` (v2) for HMAC-SHA256 signature verification, zero-downtime dual-key rotation (`WebhookKeys`), webhook handshakes, and decision-oriented `BotStrategy` processing. - **Engine Rules Integration**: Uses the `com.fortemate:dicechess-engine_3:0.3.0` JvmApi facade from Maven Central for strict DFEN parsing, legal turn path generation, and game state evaluation. ## Architecture @@ -41,12 +41,13 @@ graph TD ## Environment Variables -| Variable | Default | Description | -|----------------------------|------------------------|------------------------------------------------------------| -| `DICECHESS_WEBHOOK_SECRET` | `""` | Per-bot secret token for HMAC-SHA256 webhook verification | -| `PORT` | `8080` | HTTP server listening port (Koyeb / Cloud Run / VPS) | -| `MODEL_PATH` | `models/baseline.onnx` | Path to the ONNX value model file | -| `JAVA_OPTS` | `-Xmx256m --enable-native-access=ALL-UNNAMED` | JVM memory, GC, and native access settings | +| Variable | Default | Description | +|---------------------------------|------------------------|------------------------------------------------------------| +| `DICECHESS_WEBHOOK_SECRET` | `""` | Active secret token for HMAC-SHA256 webhook verification | +| `DICECHESS_WEBHOOK_NEXT_SECRET` | `""` | Optional pending secret token for zero-downtime rotation | +| `PORT` | `8080` | HTTP server listening port (Koyeb / Cloud Run / VPS) | +| `MODEL_PATH` | `models/baseline.onnx` | Path to the ONNX value model file | +| `JAVA_OPTS` | `-Xmx256m --enable-native-access=ALL-UNNAMED` | JVM memory, GC, and native access settings | ## Quick Start @@ -140,7 +141,7 @@ To create a custom bot strategy: } } ``` -2. Pass your strategy to `WebhookHandler` in `Main.java`. +2. Pass your strategy to `WebhookHandler` in `Main.java` (since `Strategy` extends `BotStrategy`, it integrates directly with the runtime's turn and optional decision cycle). ## Contributing & Security diff --git a/pom.xml b/pom.xml index b22f2df..6369199 100644 --- a/pom.xml +++ b/pom.xml @@ -38,7 +38,7 @@ UTF-8 0.12.0 - 1.0.1 + 2.0.0 1.29.0 6.1.3 1.6.3 @@ -52,7 +52,7 @@ - lv.id.jc + com.fortemate dicechess-bot-runtime ${dicechess.bot-runtime.version} diff --git a/src/main/java/com/fortemate/dicechess/bot/Main.java b/src/main/java/com/fortemate/dicechess/bot/Main.java index fcf9b0e..ba39d0f 100644 --- a/src/main/java/com/fortemate/dicechess/bot/Main.java +++ b/src/main/java/com/fortemate/dicechess/bot/Main.java @@ -1,13 +1,15 @@ package com.fortemate.dicechess.bot; +import com.fortemate.dicechess.runtime.CustomHandlerServer; +import com.fortemate.dicechess.runtime.WebhookHandler; +import com.fortemate.dicechess.runtime.WebhookKeys; import com.sun.net.httpserver.HttpServer; -import lv.id.jc.dicechess.runtime.CustomHandlerServer; -import lv.id.jc.dicechess.runtime.WebhookHandler; import java.io.IOException; import java.lang.System.Logger; import java.lang.System.Logger.Level; import java.nio.charset.StandardCharsets; +import java.util.Map; /** * Entry point for the Dice Chess Java bot starter template. @@ -30,22 +32,16 @@ private Main() { */ @SuppressWarnings("java:S1172") public static void main(String[] args) { - var secret = System.getenv().getOrDefault("DICECHESS_WEBHOOK_SECRET", ""); - if (secret.isEmpty()) { - logger.log(Level.WARNING, "DICECHESS_WEBHOOK_SECRET is not set — webhook verification handshake may fail"); - } - + var keys = resolveWebhookKeys(); var modelPath = System.getenv().getOrDefault("MODEL_PATH", "models/baseline.onnx"); var port = resolvePort(); var evaluator = new OnnxEvaluator(modelPath); var strategy = new OnnxStrategy(evaluator); - var handler = new WebhookHandler(secret, strategy); - HttpServer server; try { - server = CustomHandlerServer.start(port, DEFAULT_WEBHOOK_PATH, handler); + server = start(port, keys, strategy); // Register health check endpoints for Koyeb / Cloud Run / Kubernetes server.createContext("/", exchange -> { var response = "OK".getBytes(StandardCharsets.UTF_8); @@ -82,6 +78,59 @@ public static void main(String[] args) { } } + /** + * Resolves the webhook keys from system environment variables. + * Falls back to a placeholder key if neither active nor pending secret is configured. + * + * @return the resolved webhook keys + */ + static WebhookKeys resolveWebhookKeys() { + return resolveWebhookKeys(System.getenv()); + } + + /** + * Resolves the webhook keys from the provided environment map. + * + * @param env the environment mapping + * @return the resolved webhook keys + */ + static WebhookKeys resolveWebhookKeys(Map env) { + try { + return WebhookKeys.fromEnvironment(env); + } catch (IllegalArgumentException _) { + logger.log(Level.WARNING, "Neither {0} nor {1} is configured — using placeholder secret", + WebhookKeys.ENV_ACTIVE_SECRET, WebhookKeys.ENV_PENDING_SECRET); + return WebhookKeys.activeOnly("unconfigured-secret"); + } + } + + /** + * Starts the webhook server on an explicit port with configured keys and strategy. + * + * @param port the listening port (0 for ephemeral) + * @param keys the webhook key configuration + * @param strategy the bot strategy + * @return the running HTTP server + * @throws IOException if the server fails to bind + */ + public static HttpServer start(int port, WebhookKeys keys, Strategy strategy) throws IOException { + var handler = new WebhookHandler(keys, strategy); + return CustomHandlerServer.start(port, DEFAULT_WEBHOOK_PATH, handler); + } + + /** + * Starts the webhook server with a single active secret. + * + * @param port the listening port (0 for ephemeral) + * @param secret the active secret + * @param strategy the bot strategy + * @return the running HTTP server + * @throws IOException if the server fails to bind + */ + public static HttpServer start(int port, String secret, Strategy strategy) throws IOException { + return start(port, WebhookKeys.activeOnly(secret), strategy); + } + /** * Resolves the server port from the PORT environment variable. * Falls back to 8080 if PORT is not set or is invalid. diff --git a/src/main/java/com/fortemate/dicechess/bot/OnnxStrategy.java b/src/main/java/com/fortemate/dicechess/bot/OnnxStrategy.java index 975b71a..b433547 100644 --- a/src/main/java/com/fortemate/dicechess/bot/OnnxStrategy.java +++ b/src/main/java/com/fortemate/dicechess/bot/OnnxStrategy.java @@ -3,7 +3,7 @@ import dicechess.engine.domain.GameState; import dicechess.engine.jvmapi.JvmApi; -import lv.id.jc.dicechess.runtime.TurnContext; +import com.fortemate.dicechess.runtime.TurnContext; import java.lang.System.Logger; import java.lang.System.Logger.Level; diff --git a/src/main/java/com/fortemate/dicechess/bot/Strategy.java b/src/main/java/com/fortemate/dicechess/bot/Strategy.java index 5036da9..e9b9b5a 100644 --- a/src/main/java/com/fortemate/dicechess/bot/Strategy.java +++ b/src/main/java/com/fortemate/dicechess/bot/Strategy.java @@ -1,16 +1,19 @@ package com.fortemate.dicechess.bot; -import lv.id.jc.dicechess.runtime.TurnContext; +import com.fortemate.dicechess.runtime.BotStrategy; +import com.fortemate.dicechess.runtime.TurnAction; +import com.fortemate.dicechess.runtime.TurnContext; import java.util.List; import java.util.function.Function; /** * Common functional interface for Java bot strategies mapping a TurnContext to move notations. - * Implementations must return a list of UCI move notations representing a complete turn. + * Extends {@link BotStrategy} to provide decision-oriented runtime integration while allowing + * simple functional implementations of {@link #chooseMoves(TurnContext)}. */ @FunctionalInterface -public interface Strategy extends Function> { +public interface Strategy extends BotStrategy, Function> { /** * Choose the best list of move notations (micro-moves forming a turn) for the given TurnContext. @@ -21,6 +24,11 @@ public interface Strategy extends Function> { */ List chooseMoves(TurnContext context); + @Override + default TurnAction onTurn(TurnContext context) { + return new TurnAction(chooseMoves(context)); + } + @Override default List apply(TurnContext context) { return chooseMoves(context); diff --git a/src/main/java/com/fortemate/dicechess/bot/package-info.java b/src/main/java/com/fortemate/dicechess/bot/package-info.java index 40166f2..5c782fa 100644 --- a/src/main/java/com/fortemate/dicechess/bot/package-info.java +++ b/src/main/java/com/fortemate/dicechess/bot/package-info.java @@ -11,7 +11,7 @@ *
  • {@link com.fortemate.dicechess.bot.Main}: Application entry point. Configures port, loads secrets from env, * instantiates evaluator and strategy, and launches the HTTP webhook server.
  • *
  • {@link com.fortemate.dicechess.bot.Strategy}: Core functional interface for decision-making logic. - * Maps a {@link lv.id.jc.dicechess.runtime.TurnContext} to a list of long algebraic move notations.
  • + * Maps a {@link com.fortemate.dicechess.runtime.TurnContext} to a list of long algebraic move notations. *
  • {@link com.fortemate.dicechess.bot.OnnxStrategy}: Primary strategy implementation. Parses DFEN via * {@link dicechess.engine.jvmapi.JvmApi}, expands full multi-move turns via {@link dicechess.engine.jvmapi.JvmApi#legalTurns}, * and scores candidate positions using {@link com.fortemate.dicechess.bot.OnnxEvaluator}.
  • @@ -25,11 +25,12 @@ * Environment Configuration * VariableDefaultDescription * {@code DICECHESS_WEBHOOK_SECRET}EmptyHMAC secret key for verifying incoming webhook requests. + * {@code DICECHESS_WEBHOOK_NEXT_SECRET}EmptyPending HMAC secret key for dual-key rotation or verification. * {@code MODEL_PATH}{@code models/baseline.onnx}Path to the ONNX model file on disk. * {@code PORT}{@code 8080}HTTP server binding port for incoming webhook deliveries. * * - * @see lv.id.jc.dicechess.runtime.CustomHandlerServer - * @see lv.id.jc.dicechess.runtime.WebhookHandler + * @see com.fortemate.dicechess.runtime.CustomHandlerServer + * @see com.fortemate.dicechess.runtime.WebhookHandler */ package com.fortemate.dicechess.bot; diff --git a/src/test/java/com/fortemate/dicechess/bot/OnnxStrategyTest.java b/src/test/java/com/fortemate/dicechess/bot/OnnxStrategyTest.java index 24408d6..d782487 100644 --- a/src/test/java/com/fortemate/dicechess/bot/OnnxStrategyTest.java +++ b/src/test/java/com/fortemate/dicechess/bot/OnnxStrategyTest.java @@ -1,6 +1,6 @@ package com.fortemate.dicechess.bot; -import lv.id.jc.dicechess.runtime.TurnContext; +import com.fortemate.dicechess.runtime.TurnContext; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -28,17 +28,23 @@ void tearDown() { } @Test - void testChooseMovesWithEmptyDfen() { - var context = new TurnContext("test-game", "", null, List.of()); + void testChooseMovesWithNullContext() { + var moves = strategy.chooseMoves(null); + assertTrue(moves.isEmpty(), "Should return empty list for null context"); + } + + @Test + void testChooseMovesWithInvalidDfen() { + var context = new TurnContext("test-game", "White", 1L, "invalid-dfen-string", null, List.of(), false); var moves = strategy.chooseMoves(context); - assertTrue(moves.isEmpty(), "Should return empty list for empty DFEN"); + assertTrue(moves.isEmpty(), "Should return empty list for invalid DFEN"); } @Test void testChooseMovesWithInitialPosition() { // Initial DFEN position with dice pool 'p' (pawn roll) for white var dfen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1 p"; - var context = new TurnContext("test-game", dfen, null, List.of()); + var context = new TurnContext("test-game", "White", 1L, dfen, null, List.of(), false); var moves = strategy.chooseMoves(context); assertFalse(moves.isEmpty(), "Should generate at least one legal move for pawn roll"); @@ -49,10 +55,28 @@ void testChooseMovesWithInitialPosition() { void testChooseMovesWithTripleDicePool() { // Initial DFEN position with dice pool 'pnb' for white var dfen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1 pnb"; - var context = new TurnContext("test-game", dfen, null, List.of()); + var context = new TurnContext("test-game", "White", 1L, dfen, null, List.of(), false); var moves = strategy.chooseMoves(context); assertFalse(moves.isEmpty(), "Should generate legal turn sequence for triple dice pool"); assertTrue(moves.size() <= 3, "Turn should contain at most 3 micro-moves"); } + + @Test + void testOnTurnReturnsTurnAction() { + var dfen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1 p"; + var context = new TurnContext("test-game", "White", 1L, dfen, null, List.of(), false); + + var action = strategy.onTurn(context); + assertNotNull(action, "onTurn should return non-null TurnAction"); + assertFalse(action.moves().isEmpty(), "onTurn moves should not be empty"); + assertFalse(action.offerDraw(), "onTurn offerDraw should default to false"); + } + + @Test + void testDefaultDrawAndDoubleDecisions() { + assertFalse(strategy.onDrawDecision(null).acceptDraw(), "Should decline draw by default"); + assertFalse(strategy.onDoubleOpportunity(null).offerDouble(), "Should roll without offering double by default"); + assertFalse(strategy.onDoubleDecision(null).acceptDouble(), "Should decline double by default"); + } } diff --git a/src/test/java/com/fortemate/dicechess/bot/WebhookIntegrationTest.java b/src/test/java/com/fortemate/dicechess/bot/WebhookIntegrationTest.java index 857c4ab..1252575 100644 --- a/src/test/java/com/fortemate/dicechess/bot/WebhookIntegrationTest.java +++ b/src/test/java/com/fortemate/dicechess/bot/WebhookIntegrationTest.java @@ -1,8 +1,9 @@ package com.fortemate.dicechess.bot; +import com.fortemate.dicechess.runtime.CustomHandlerServer; +import com.fortemate.dicechess.runtime.Signatures; +import com.fortemate.dicechess.runtime.WebhookHandler; import com.sun.net.httpserver.HttpServer; -import lv.id.jc.dicechess.runtime.CustomHandlerServer; -import lv.id.jc.dicechess.runtime.WebhookHandler; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -12,11 +13,17 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; class WebhookIntegrationTest { + private static final String SECRET = "test-secret"; + private HttpServer server; private OnnxEvaluator evaluator; @@ -24,10 +31,24 @@ class WebhookIntegrationTest { void setUp() throws IOException { evaluator = new OnnxEvaluator(null); var strategy = new OnnxStrategy(evaluator); - var handler = new WebhookHandler("test-secret", strategy); + var handler = new WebhookHandler(SECRET, strategy); // Bind on ephemeral port 0 server = CustomHandlerServer.start(0, "/api/webhook", handler); + server.createContext("/health", exchange -> { + var response = "OK".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, response.length); + try (var os = exchange.getResponseBody()) { + os.write(response); + } + }); + server.createContext("/", exchange -> { + var response = "OK".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, response.length); + try (var os = exchange.getResponseBody()) { + os.write(response); + } + }); } @AfterEach @@ -54,4 +75,101 @@ void testWebhookRejectsUnauthenticatedRequest() throws Exception { var response = client.send(request, HttpResponse.BodyHandlers.ofString()); assertEquals(400, response.statusCode(), "Unauthenticated request should return 400"); } + + @Test + void testVerificationHandshake() throws Exception { + var port = server.getAddress().getPort(); + var client = HttpClient.newHttpClient(); + + var body = "{\"type\":\"verification\",\"nonce\":\"test-nonce-123\"}"; + var request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + port + "/api/webhook")) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + + var response = client.send(request, HttpResponse.BodyHandlers.ofString()); + assertEquals(200, response.statusCode(), "Handshake should succeed with 200"); + assertTrue(response.body().contains("test-nonce-123"), "Response should echo the nonce"); + } + + @Test + void testSignedYourTurnDelivery() throws Exception { + var port = server.getAddress().getPort(); + var client = HttpClient.newHttpClient(); + + var body = """ + {"type":"yourTurn","gameId":"game-123","seat":"White","state":{"version":1,"dfen":"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1 p","activeSeat":"White","dicePending":true}} + """.strip(); + var now = Instant.now().getEpochSecond(); + var signature = Signatures.sign(SECRET, now, body); + + var request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + port + "/api/webhook")) + .header("Content-Type", "application/json") + .header(WebhookHandler.TIMESTAMP_HEADER, String.valueOf(now)) + .header(WebhookHandler.SIGNATURE_HEADER, signature) + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + + var response = client.send(request, HttpResponse.BodyHandlers.ofString()); + assertEquals(200, response.statusCode(), "Signed delivery should succeed with 200"); + assertTrue(response.body().contains("\"moves\":["), "Response should contain moves"); + assertTrue(response.body().contains("\"offerDraw\":false"), "Response should specify offerDraw"); + } + + @Test + void testSignedDeliveryWithInvalidSignature() throws Exception { + var port = server.getAddress().getPort(); + var client = HttpClient.newHttpClient(); + + var body = """ + {"type":"yourTurn","gameId":"game-123","seat":"White","state":{"version":1,"dfen":"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1 p","activeSeat":"White","dicePending":true}} + """.strip(); + var now = Instant.now().getEpochSecond(); + + var request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + port + "/api/webhook")) + .header("Content-Type", "application/json") + .header(WebhookHandler.TIMESTAMP_HEADER, String.valueOf(now)) + .header(WebhookHandler.SIGNATURE_HEADER, "invalid-signature") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + + var response = client.send(request, HttpResponse.BodyHandlers.ofString()); + assertEquals(401, response.statusCode(), "Bad signature should return 401"); + } + + @Test + void testHealthCheckAndRootEndpoints() throws Exception { + var port = server.getAddress().getPort(); + var client = HttpClient.newHttpClient(); + + var healthReq = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + port + "/health")) + .GET() + .build(); + var healthResp = client.send(healthReq, HttpResponse.BodyHandlers.ofString()); + assertEquals(200, healthResp.statusCode()); + assertEquals("OK", healthResp.body()); + + var rootReq = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + port + "/")) + .GET() + .build(); + var rootResp = client.send(rootReq, HttpResponse.BodyHandlers.ofString()); + assertEquals(200, rootResp.statusCode()); + assertEquals("OK", rootResp.body()); + } + + @Test + void testMainResolveWebhookKeys() { + var configured = Main.resolveWebhookKeys(Map.of("DICECHESS_WEBHOOK_SECRET", "custom-secret")); + assertTrue(configured.hasActive()); + assertEquals("custom-secret", configured.active()); + + var unconfigured = Main.resolveWebhookKeys(Map.of()); + assertTrue(unconfigured.hasActive()); + assertEquals("unconfigured-secret", unconfigured.active()); + } } From 06688d6732085fa7abbcb19c3ae15f503dde61bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jegors=20=C4=8Cemisovs?= Date: Sun, 13 Sep 2026 19:44:52 +0300 Subject: [PATCH 2/3] fix: fail closed on missing webhook keys and expand test coverage --- .../com/fortemate/dicechess/bot/Main.java | 107 +++++++++++------- .../com/fortemate/dicechess/bot/MainTest.java | 100 ++++++++++++++++ .../dicechess/bot/OnnxStrategyTest.java | 7 ++ .../dicechess/bot/WebhookIntegrationTest.java | 33 +----- 4 files changed, 173 insertions(+), 74 deletions(-) create mode 100644 src/test/java/com/fortemate/dicechess/bot/MainTest.java diff --git a/src/main/java/com/fortemate/dicechess/bot/Main.java b/src/main/java/com/fortemate/dicechess/bot/Main.java index ba39d0f..016ec50 100644 --- a/src/main/java/com/fortemate/dicechess/bot/Main.java +++ b/src/main/java/com/fortemate/dicechess/bot/Main.java @@ -10,6 +10,7 @@ import java.lang.System.Logger.Level; import java.nio.charset.StandardCharsets; import java.util.Map; +import java.util.Optional; /** * Entry point for the Dice Chess Java bot starter template. @@ -32,35 +33,42 @@ private Main() { */ @SuppressWarnings("java:S1172") public static void main(String[] args) { - var keys = resolveWebhookKeys(); - var modelPath = System.getenv().getOrDefault("MODEL_PATH", "models/baseline.onnx"); - var port = resolvePort(); + var server = startApplication(System.getenv()); + if (server != null) { + try { + Thread.currentThread().join(); + } catch (InterruptedException _) { + Thread.currentThread().interrupt(); + } + } + } + + /** + * Starts the application server using configuration from the provided environment map. + * Aborts and returns null if webhook keys are not configured or binding fails. + * + * @param env the environment variables map + * @return the running HttpServer, or null if initialization aborted + */ + static HttpServer startApplication(Map env) { + var keysOpt = resolveWebhookKeys(env); + if (keysOpt.isEmpty()) { + return null; + } + + var modelPath = env.getOrDefault("MODEL_PATH", "models/baseline.onnx"); + var port = resolvePort(env.get("PORT")); var evaluator = new OnnxEvaluator(modelPath); var strategy = new OnnxStrategy(evaluator); HttpServer server; try { - server = start(port, keys, strategy); - // Register health check endpoints for Koyeb / Cloud Run / Kubernetes - server.createContext("/", exchange -> { - var response = "OK".getBytes(StandardCharsets.UTF_8); - exchange.sendResponseHeaders(200, response.length); - try (var os = exchange.getResponseBody()) { - os.write(response); - } - }); - server.createContext("/health", exchange -> { - var response = "OK".getBytes(StandardCharsets.UTF_8); - exchange.sendResponseHeaders(200, response.length); - try (var os = exchange.getResponseBody()) { - os.write(response); - } - }); - } catch (IOException e) { + server = start(port, keysOpt.get(), strategy); + } catch (IOException | IllegalArgumentException e) { logger.log(Level.ERROR, "Failed to start HTTP server on port {0}: {1}", port, e.getMessage()); evaluator.close(); - return; + return null; } logger.log(Level.INFO, "Dice Chess Java Bot initialized and listening on port {0} at path {1}", port, DEFAULT_WEBHOOK_PATH); @@ -71,41 +79,37 @@ public static void main(String[] args) { evaluator.close(); })); - try { - Thread.currentThread().join(); - } catch (InterruptedException _) { - Thread.currentThread().interrupt(); - } + return server; } /** * Resolves the webhook keys from system environment variables. - * Falls back to a placeholder key if neither active nor pending secret is configured. * - * @return the resolved webhook keys + * @return the resolved webhook keys, or empty if neither active nor pending secret is configured */ - static WebhookKeys resolveWebhookKeys() { + static Optional resolveWebhookKeys() { return resolveWebhookKeys(System.getenv()); } /** * Resolves the webhook keys from the provided environment map. + * Fails closed by logging an error and returning empty if keys are missing or invalid. * * @param env the environment mapping - * @return the resolved webhook keys + * @return the resolved webhook keys, or empty if not configured */ - static WebhookKeys resolveWebhookKeys(Map env) { + static Optional resolveWebhookKeys(Map env) { try { - return WebhookKeys.fromEnvironment(env); - } catch (IllegalArgumentException _) { - logger.log(Level.WARNING, "Neither {0} nor {1} is configured — using placeholder secret", - WebhookKeys.ENV_ACTIVE_SECRET, WebhookKeys.ENV_PENDING_SECRET); - return WebhookKeys.activeOnly("unconfigured-secret"); + return Optional.of(WebhookKeys.fromEnvironment(env)); + } catch (IllegalArgumentException e) { + logger.log(Level.ERROR, "Missing or invalid webhook signing keys: {0}", e.getMessage()); + return Optional.empty(); } } /** - * Starts the webhook server on an explicit port with configured keys and strategy. + * Starts the webhook server on an explicit port with configured keys and strategy, + * registering default health endpoints. * * @param port the listening port (0 for ephemeral) * @param keys the webhook key configuration @@ -115,7 +119,9 @@ static WebhookKeys resolveWebhookKeys(Map env) { */ public static HttpServer start(int port, WebhookKeys keys, Strategy strategy) throws IOException { var handler = new WebhookHandler(keys, strategy); - return CustomHandlerServer.start(port, DEFAULT_WEBHOOK_PATH, handler); + var server = CustomHandlerServer.start(port, DEFAULT_WEBHOOK_PATH, handler); + registerHealthEndpoints(server); + return server; } /** @@ -131,14 +137,31 @@ public static HttpServer start(int port, String secret, Strategy strategy) throw return start(port, WebhookKeys.activeOnly(secret), strategy); } + static void registerHealthEndpoints(HttpServer server) { + server.createContext("/", exchange -> { + var response = "OK".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, response.length); + try (var os = exchange.getResponseBody()) { + os.write(response); + } + }); + server.createContext("/health", exchange -> { + var response = "OK".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, response.length); + try (var os = exchange.getResponseBody()) { + os.write(response); + } + }); + } + /** - * Resolves the server port from the PORT environment variable. - * Falls back to 8080 if PORT is not set or is invalid. + * Resolves the server port from the given port string. + * Falls back to 8080 if string is null, blank, or invalid. * + * @param portStr the port string from environment * @return the resolved port number */ - private static int resolvePort() { - var portStr = System.getenv("PORT"); + static int resolvePort(String portStr) { if (portStr != null && !portStr.isBlank()) { try { return Integer.parseInt(portStr); diff --git a/src/test/java/com/fortemate/dicechess/bot/MainTest.java b/src/test/java/com/fortemate/dicechess/bot/MainTest.java new file mode 100644 index 0000000..ae13c7b --- /dev/null +++ b/src/test/java/com/fortemate/dicechess/bot/MainTest.java @@ -0,0 +1,100 @@ +package com.fortemate.dicechess.bot; + +import org.junit.jupiter.api.Test; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class MainTest { + + @Test + void testResolvePort() { + assertEquals(8080, Main.resolvePort(null)); + assertEquals(8080, Main.resolvePort("")); + assertEquals(8080, Main.resolvePort(" ")); + assertEquals(9090, Main.resolvePort("9090")); + assertEquals(8080, Main.resolvePort("not-a-number")); + } + + @Test + void testResolveWebhookKeysWithActiveOnly() { + var keys = Main.resolveWebhookKeys(Map.of("DICECHESS_WEBHOOK_SECRET", "active-key")); + assertTrue(keys.isPresent()); + assertEquals("active-key", keys.get().active()); + assertNull(keys.get().pending()); + } + + @Test + void testResolveWebhookKeysWithPendingOnly() { + var keys = Main.resolveWebhookKeys(Map.of("DICECHESS_WEBHOOK_NEXT_SECRET", "pending-key")); + assertTrue(keys.isPresent()); + assertNull(keys.get().active()); + assertEquals("pending-key", keys.get().pending()); + } + + @Test + void testResolveWebhookKeysWithBothKeys() { + var keys = Main.resolveWebhookKeys(Map.of( + "DICECHESS_WEBHOOK_SECRET", "active-key", + "DICECHESS_WEBHOOK_NEXT_SECRET", "pending-key" + )); + assertTrue(keys.isPresent()); + assertEquals("active-key", keys.get().active()); + assertEquals("pending-key", keys.get().pending()); + } + + @Test + void testResolveWebhookKeysFailsClosedWhenMissingOrBlank() { + assertTrue(Main.resolveWebhookKeys(Map.of()).isEmpty()); + assertTrue(Main.resolveWebhookKeys(Map.of("DICECHESS_WEBHOOK_SECRET", " ")).isEmpty()); + } + + @Test + void testResolveWebhookKeysFromSystemEnvironmentDoesNotThrow() { + assertDoesNotThrow(() -> Main.resolveWebhookKeys()); + } + + @Test + void testStartApplicationFailsClosedWithoutKeys() { + var server = Main.startApplication(Map.of()); + assertNull(server, "startApplication must return null when webhook keys are absent"); + } + + @Test + void testStartApplicationSuccessWithValidKeys() throws Exception { + var server = Main.startApplication(Map.of( + "DICECHESS_WEBHOOK_SECRET", "test-secret", + "PORT", "0" + )); + assertNotNull(server, "startApplication must return running server when keys are provided"); + try { + var port = server.getAddress().getPort(); + var client = HttpClient.newHttpClient(); + var req = HttpRequest.newBuilder(URI.create("http://localhost:" + port + "/health")).GET().build(); + var resp = client.send(req, HttpResponse.BodyHandlers.ofString()); + assertEquals(200, resp.statusCode()); + assertEquals("OK", resp.body()); + } finally { + server.stop(0); + } + } + + @Test + void testStartApplicationFailsGracefullyOnInvalidPort() { + var server = Main.startApplication(Map.of( + "DICECHESS_WEBHOOK_SECRET", "test-secret", + "PORT", "-1" + )); + assertNull(server, "startApplication should return null on port bind failure"); + } + + @Test + void testMainMethodExecutesCleanlyWhenUnconfigured() { + assertDoesNotThrow(() -> Main.main(new String[0])); + } +} diff --git a/src/test/java/com/fortemate/dicechess/bot/OnnxStrategyTest.java b/src/test/java/com/fortemate/dicechess/bot/OnnxStrategyTest.java index d782487..4f2277e 100644 --- a/src/test/java/com/fortemate/dicechess/bot/OnnxStrategyTest.java +++ b/src/test/java/com/fortemate/dicechess/bot/OnnxStrategyTest.java @@ -79,4 +79,11 @@ void testDefaultDrawAndDoubleDecisions() { assertFalse(strategy.onDoubleOpportunity(null).offerDouble(), "Should roll without offering double by default"); assertFalse(strategy.onDoubleDecision(null).acceptDouble(), "Should decline double by default"); } + + @Test + void testStrategyApplyMatchesChooseMoves() { + var dfen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1 p"; + var context = new TurnContext("test-game", "White", 1L, dfen, null, List.of(), false); + assertEquals(strategy.chooseMoves(context), strategy.apply(context)); + } } diff --git a/src/test/java/com/fortemate/dicechess/bot/WebhookIntegrationTest.java b/src/test/java/com/fortemate/dicechess/bot/WebhookIntegrationTest.java index 1252575..0953f57 100644 --- a/src/test/java/com/fortemate/dicechess/bot/WebhookIntegrationTest.java +++ b/src/test/java/com/fortemate/dicechess/bot/WebhookIntegrationTest.java @@ -1,6 +1,5 @@ package com.fortemate.dicechess.bot; -import com.fortemate.dicechess.runtime.CustomHandlerServer; import com.fortemate.dicechess.runtime.Signatures; import com.fortemate.dicechess.runtime.WebhookHandler; import com.sun.net.httpserver.HttpServer; @@ -13,9 +12,7 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; -import java.nio.charset.StandardCharsets; import java.time.Instant; -import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -31,24 +28,7 @@ class WebhookIntegrationTest { void setUp() throws IOException { evaluator = new OnnxEvaluator(null); var strategy = new OnnxStrategy(evaluator); - var handler = new WebhookHandler(SECRET, strategy); - - // Bind on ephemeral port 0 - server = CustomHandlerServer.start(0, "/api/webhook", handler); - server.createContext("/health", exchange -> { - var response = "OK".getBytes(StandardCharsets.UTF_8); - exchange.sendResponseHeaders(200, response.length); - try (var os = exchange.getResponseBody()) { - os.write(response); - } - }); - server.createContext("/", exchange -> { - var response = "OK".getBytes(StandardCharsets.UTF_8); - exchange.sendResponseHeaders(200, response.length); - try (var os = exchange.getResponseBody()) { - os.write(response); - } - }); + server = Main.start(0, SECRET, strategy); } @AfterEach @@ -161,15 +141,4 @@ void testHealthCheckAndRootEndpoints() throws Exception { assertEquals(200, rootResp.statusCode()); assertEquals("OK", rootResp.body()); } - - @Test - void testMainResolveWebhookKeys() { - var configured = Main.resolveWebhookKeys(Map.of("DICECHESS_WEBHOOK_SECRET", "custom-secret")); - assertTrue(configured.hasActive()); - assertEquals("custom-secret", configured.active()); - - var unconfigured = Main.resolveWebhookKeys(Map.of()); - assertTrue(unconfigured.hasActive()); - assertEquals("unconfigured-secret", unconfigured.active()); - } } From 47b935d686a5c587ff0b352e2da2c05f22d54294 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jegors=20=C4=8Cemisovs?= Date: Sun, 13 Sep 2026 19:47:54 +0300 Subject: [PATCH 3/3] test: resolve SonarCloud code smells in tests --- .../java/com/fortemate/dicechess/bot/MainTest.java | 14 ++++++++------ .../dicechess/bot/WebhookIntegrationTest.java | 10 +++++----- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/test/java/com/fortemate/dicechess/bot/MainTest.java b/src/test/java/com/fortemate/dicechess/bot/MainTest.java index ae13c7b..552904e 100644 --- a/src/test/java/com/fortemate/dicechess/bot/MainTest.java +++ b/src/test/java/com/fortemate/dicechess/bot/MainTest.java @@ -56,7 +56,8 @@ void testResolveWebhookKeysFailsClosedWhenMissingOrBlank() { @Test void testResolveWebhookKeysFromSystemEnvironmentDoesNotThrow() { - assertDoesNotThrow(() -> Main.resolveWebhookKeys()); + org.junit.jupiter.api.function.ThrowingSupplier supplier = Main::resolveWebhookKeys; + assertDoesNotThrow(supplier); } @Test @@ -74,11 +75,12 @@ void testStartApplicationSuccessWithValidKeys() throws Exception { assertNotNull(server, "startApplication must return running server when keys are provided"); try { var port = server.getAddress().getPort(); - var client = HttpClient.newHttpClient(); - var req = HttpRequest.newBuilder(URI.create("http://localhost:" + port + "/health")).GET().build(); - var resp = client.send(req, HttpResponse.BodyHandlers.ofString()); - assertEquals(200, resp.statusCode()); - assertEquals("OK", resp.body()); + try (var client = HttpClient.newHttpClient()) { + var req = HttpRequest.newBuilder(URI.create("http://localhost:" + port + "/health")).GET().build(); + var resp = client.send(req, HttpResponse.BodyHandlers.ofString()); + assertEquals(200, resp.statusCode()); + assertEquals("OK", resp.body()); + } } finally { server.stop(0); } diff --git a/src/test/java/com/fortemate/dicechess/bot/WebhookIntegrationTest.java b/src/test/java/com/fortemate/dicechess/bot/WebhookIntegrationTest.java index 0953f57..d51803f 100644 --- a/src/test/java/com/fortemate/dicechess/bot/WebhookIntegrationTest.java +++ b/src/test/java/com/fortemate/dicechess/bot/WebhookIntegrationTest.java @@ -23,9 +23,11 @@ class WebhookIntegrationTest { private HttpServer server; private OnnxEvaluator evaluator; + private HttpClient client; @BeforeEach void setUp() throws IOException { + client = HttpClient.newHttpClient(); evaluator = new OnnxEvaluator(null); var strategy = new OnnxStrategy(evaluator); server = Main.start(0, SECRET, strategy); @@ -39,12 +41,14 @@ void tearDown() { if (evaluator != null) { evaluator.close(); } + if (client != null) { + client.close(); + } } @Test void testWebhookRejectsUnauthenticatedRequest() throws Exception { var port = server.getAddress().getPort(); - var client = HttpClient.newHttpClient(); // A bare GET without proper HMAC signature headers should be rejected var request = HttpRequest.newBuilder() @@ -59,7 +63,6 @@ void testWebhookRejectsUnauthenticatedRequest() throws Exception { @Test void testVerificationHandshake() throws Exception { var port = server.getAddress().getPort(); - var client = HttpClient.newHttpClient(); var body = "{\"type\":\"verification\",\"nonce\":\"test-nonce-123\"}"; var request = HttpRequest.newBuilder() @@ -76,7 +79,6 @@ void testVerificationHandshake() throws Exception { @Test void testSignedYourTurnDelivery() throws Exception { var port = server.getAddress().getPort(); - var client = HttpClient.newHttpClient(); var body = """ {"type":"yourTurn","gameId":"game-123","seat":"White","state":{"version":1,"dfen":"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1 p","activeSeat":"White","dicePending":true}} @@ -101,7 +103,6 @@ void testSignedYourTurnDelivery() throws Exception { @Test void testSignedDeliveryWithInvalidSignature() throws Exception { var port = server.getAddress().getPort(); - var client = HttpClient.newHttpClient(); var body = """ {"type":"yourTurn","gameId":"game-123","seat":"White","state":{"version":1,"dfen":"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1 p","activeSeat":"White","dicePending":true}} @@ -123,7 +124,6 @@ void testSignedDeliveryWithInvalidSignature() throws Exception { @Test void testHealthCheckAndRootEndpoints() throws Exception { var port = server.getAddress().getPort(); - var client = HttpClient.newHttpClient(); var healthReq = HttpRequest.newBuilder() .uri(URI.create("http://localhost:" + port + "/health"))