diff --git a/src/test/java/test/it/IT_01_AddUser.java b/src/test/java/test/it/IT_01_AddUser.java index 230d2c6..6c39838 100644 --- a/src/test/java/test/it/IT_01_AddUser.java +++ b/src/test/java/test/it/IT_01_AddUser.java @@ -1,10 +1,7 @@ package test.it; import org.junit.jupiter.api.Test; -import test.it.utils.JsonBuilders; -import test.it.utils.JsonParsers; -import test.it.utils.TestConfig; -import test.it.utils.WsTestClient; +import test.it.utils.*; import java.time.Duration; @@ -12,7 +9,7 @@ import static org.junit.jupiter.api.Assertions.*; public class IT_01_AddUser { - // ANSI цвета (работает в большинстве терминалов) + // ANSI цвета private static final String R = "\u001B[0m"; private static final String G = "\u001B[32m"; private static final String Y = "\u001B[33m"; @@ -33,23 +30,29 @@ public class IT_01_AddUser { System.out.println(G + "✅ " + s + R); } - private static void warn(String s) { - System.out.println(Y + "⚠️ " + s + R); - } - private static void boom(String s) { System.out.println(RED + "****************************************************************" + R); System.out.println(RED + "❌ " + s + R); System.out.println(RED + "****************************************************************" + R); } + public static void main(String[] args) { + // чтобы тест можно было запускать вообще без JUnit + ItRunContext.initIfNeeded(); + new IT_01_AddUser().addUser_shouldReturn200_orAlreadyExists(); + } + @Test void addUser_shouldReturn200_orAlreadyExists() { + ItRunContext.initIfNeeded(); + title("AddUserIT: проверка добавления пользователя (200 OK) или 'уже существует' (409 USER_ALREADY_EXISTS)"); + System.out.println("Используем:"); + System.out.println(" login = " + TestConfig.LOGIN()); + System.out.println(" blockchainName = " + TestConfig.BCH_NAME()); System.out.println("Ожидание:"); - System.out.println(" - сервер принимает AddUser и возвращает:"); - System.out.println(" * 200 (пользователь создан/добавлен)"); - System.out.println(" * либо 409 + payload.code=USER_ALREADY_EXISTS (если уже есть)\n"); + System.out.println(" - 200 (создан)"); + System.out.println(" - или 409 + payload.code=USER_ALREADY_EXISTS\n"); try (WsTestClient client = new WsTestClient(TestConfig.WS_URI)) { @@ -73,12 +76,9 @@ public class IT_01_AddUser { boolean already = (st == 409); if (already) { - // ВАЖНО: payload.code (не errorCode) String code = JsonParsers.errorCode(resp); - System.out.println("ℹ️ server_code=" + code); - // Если 409 пришёл, требуем понятный code try { assertEquals("USER_ALREADY_EXISTS", code, "Expected code=USER_ALREADY_EXISTS, but got: " + code + ", resp=" + resp); @@ -99,9 +99,8 @@ public class IT_01_AddUser { } } catch (AssertionError | RuntimeException e) { - // чтобы “красным” было видно даже если Gradle/IDE печатает стек отдельно boom("ТЕСТ УПАЛ: AddUserIT. Причина: " + e.getMessage()); throw e; } } -} +} \ No newline at end of file diff --git a/src/test/java/test/it/IT_02_Sessions.java b/src/test/java/test/it/IT_02_Sessions.java index 4b00f33..4989e0a 100644 --- a/src/test/java/test/it/IT_02_Sessions.java +++ b/src/test/java/test/it/IT_02_Sessions.java @@ -2,10 +2,7 @@ package test.it; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; -import test.it.utils.JsonBuilders; -import test.it.utils.JsonParsers; -import test.it.utils.TestConfig; -import test.it.utils.WsTestClient; +import test.it.utils.*; import java.time.Duration; import java.util.List; @@ -39,10 +36,6 @@ public class IT_02_Sessions { System.out.println(G + "✅ " + s + R); } - private static void warn(String s) { - System.out.println(Y + "⚠️ " + s + R); - } - private static void boom(String s) { System.out.println(RED + "****************************************************************" + R); System.out.println(RED + "❌ " + s + R); @@ -72,8 +65,16 @@ public class IT_02_Sessions { } } + public static void main(String[] args) { + ItRunContext.initIfNeeded(); + ensureUserExists(); + new IT_02_Sessions().sessions_flow_shouldCreateListRefreshCloseCorrectly(); + } + @BeforeAll static void ensureUserExists() { + ItRunContext.initIfNeeded(); + title("SessionsIT (BeforeAll): предусловие — пользователь должен существовать (AddUser: 200 или 409)"); try (WsTestClient client = new WsTestClient(TestConfig.WS_URI)) { @@ -86,7 +87,6 @@ public class IT_02_Sessions { int st = JsonParsers.status(resp); - // 200 или "уже есть" — ок if (st == 200) { ok("BeforeAll: пользователь создан/добавлен (status=200)"); } else if (st == 409) { @@ -106,7 +106,11 @@ public class IT_02_Sessions { @Test void sessions_flow_shouldCreateListRefreshCloseCorrectly() { + ItRunContext.initIfNeeded(); + title("SessionsIT: полный сценарий сессий (создать 2, проверить list, refresh/close, проверить очистку)"); + System.out.println("Используем:"); + System.out.println(" login = " + TestConfig.LOGIN()); System.out.println("Ожидание сценария:"); System.out.println(" 1) Создаём SESSION1 через AuthChallenge + CreateAuthSession"); System.out.println(" 2) Создаём SESSION2 и делаем ListSessions внутри неё (AUTH_STATUS_USER) → должны быть SESSION1 и SESSION2"); @@ -120,7 +124,6 @@ public class IT_02_Sessions { String s2Id, s2Pwd; try { - // --- create session1 --- stepTitle("ШАГ 1: создать SESSION1 (AuthChallenge -> CreateAuthSession)"); try (WsTestClient c = new WsTestClient(TestConfig.WS_URI)) { String r1 = "it-auth-1"; @@ -150,7 +153,6 @@ public class IT_02_Sessions { ok("SESSION1 получена: sessionId=" + s1Id + ", sessionPwd=[получен]"); } - // --- create session2 and list inside (AUTH_STATUS_USER) --- stepTitle("ШАГ 2: создать SESSION2 и ListSessions внутри неё (AUTH_STATUS_USER) → должны быть SESSION1+SESSION2"); try (WsTestClient c = new WsTestClient(TestConfig.WS_URI)) { String r1 = "it-auth-2"; @@ -178,7 +180,6 @@ public class IT_02_Sessions { assertNotNull(s2Pwd); ok("SESSION2 получена: sessionId=" + s2Id + ", sessionPwd=[получен]"); - // list inside session2 (у тебя это AUTH_STATUS_USER без подписи) String r3 = "it-list-in-session2"; String req3 = JsonBuilders.listSessions(r3, 0L, ""); send("ListSessions(in SESSION2)", req3); @@ -194,7 +195,6 @@ public class IT_02_Sessions { ok("Проверка OK: список содержит SESSION1 и SESSION2"); } - // --- list in AUTH_IN_PROGRESS (подпись по nonce) --- stepTitle("ШАГ 3: ListSessions в AUTH_IN_PROGRESS (nonce+signature) → должны быть SESSION1+SESSION2"); try (WsTestClient c = new WsTestClient(TestConfig.WS_URI)) { String r1 = "it-auth-list"; @@ -228,7 +228,6 @@ public class IT_02_Sessions { ok("Проверка OK: AUTH_IN_PROGRESS список содержит SESSION1 и SESSION2"); } - // --- refresh session1 and close session2 (from session1) --- stepTitle("ШАГ 4: Refresh SESSION1 (входим) и Close SESSION2 (из SESSION1)"); try (WsTestClient c = new WsTestClient(TestConfig.WS_URI)) { @@ -252,7 +251,6 @@ public class IT_02_Sessions { ok("SESSION2 закрыта"); } - // --- verify only session1 remains (AUTH_IN_PROGRESS list) --- stepTitle("ШАГ 5: ListSessions(AUTH_IN_PROGRESS) → должна остаться только SESSION1"); try (WsTestClient c = new WsTestClient(TestConfig.WS_URI)) { String r1 = "it-auth-list2"; @@ -284,7 +282,6 @@ public class IT_02_Sessions { ok("Проверка OK: осталась только SESSION1"); } - // --- close session1 in AUTH_IN_PROGRESS --- stepTitle("ШАГ 6: Close SESSION1 в AUTH_IN_PROGRESS"); try (WsTestClient c = new WsTestClient(TestConfig.WS_URI)) { String r1 = "it-auth-close-s1"; @@ -310,7 +307,6 @@ public class IT_02_Sessions { ok("SESSION1 закрыта"); } - // --- verify empty list --- stepTitle("ШАГ 7: ListSessions(AUTH_IN_PROGRESS) → ожидаем пустой список"); try (WsTestClient c = new WsTestClient(TestConfig.WS_URI)) { String r1 = "it-auth-list-empty"; @@ -348,4 +344,4 @@ public class IT_02_Sessions { throw e; } } -} +} \ No newline at end of file diff --git a/src/test/java/test/it/IT_RunAllMain.java b/src/test/java/test/it/IT_RunAllMain.java new file mode 100644 index 0000000..f03f5fd --- /dev/null +++ b/src/test/java/test/it/IT_RunAllMain.java @@ -0,0 +1,82 @@ +package test.it; + +import test.it.utils.TestConfig; +import test.it.utils.TestColors; +import test.it.utils.ItRunContext; +import test.it.ws.IT_03_AddBlock_NoAuth; + +import java.io.IOException; +import java.nio.file.*; +import java.util.Comparator; + +/** + * Ручной запуск всех IT тестов БЕЗ JUnit / Suite. + * + * Делает: + * 1) чистит папку data/ + * 2) запускает 3 теста по очереди (через их main) + * + * Запуск из IDE: + * Run 'main' этого класса + * + * Запуск из консоли: + * ./gradlew testClasses + * java -cp build/classes/java/test:build/resources/test:build/classes/java/main:build/resources/main <тут_свой_classpath> test.it.IT_RunAllMain + * + * (Classpath зависит от твоего Gradle, но в IDE проще всего) + */ +public class IT_RunAllMain { + + public static void main(String[] args) { + try { + ItRunContext.initIfNeeded(); + + banner("ШАГ 0: очистка data/"); + cleanupDataDir(TestConfig.DATA_DIR); + + banner("ШАГ 1: IT_01_AddUser"); + IT_01_AddUser.main(new String[0]); + + banner("ШАГ 2: IT_02_Sessions"); + IT_02_Sessions.main(new String[0]); + + banner("ШАГ 3: IT_03_AddBlock_NoAuth"); + IT_03_AddBlock_NoAuth.main(new String[0]); + + System.out.println(TestColors.G + "\n✅ ВСЕ 3 IT ТЕСТА УСПЕШНО ЗАВЕРШЕНЫ\n" + TestColors.R); + } catch (Throwable t) { + System.out.println(TestColors.RED + "\n❌ IT_RunAllMain: ПРОГОН УПАЛ\n" + TestColors.R); + t.printStackTrace(System.out); + System.exit(1); + } + } + + private static void banner(String s) { + System.out.println(TestColors.C + "\n============================================================" + TestColors.R); + System.out.println(TestColors.C + s + TestColors.R); + System.out.println(TestColors.C + "============================================================\n" + TestColors.R); + } + + private static void cleanupDataDir(String dirName) throws IOException { + Path dir = Paths.get(dirName); + if (!Files.exists(dir)) { + System.out.println("ℹ️ data dir not found: " + dir.toAbsolutePath() + " (создаю)"); + Files.createDirectories(dir); + return; + } + + // удаляем ВСЁ внутри папки, но саму папку оставляем + Files.walk(dir) + .sorted(Comparator.reverseOrder()) + .filter(p -> !p.equals(dir)) + .forEach(p -> { + try { + Files.deleteIfExists(p); + } catch (IOException e) { + throw new RuntimeException("Не смог удалить: " + p.toAbsolutePath(), e); + } + }); + + System.out.println("✅ data очищена: " + dir.toAbsolutePath()); + } +} \ No newline at end of file diff --git a/src/test/java/test/it/utils/ItRunContext.java b/src/test/java/test/it/utils/ItRunContext.java index 953fb07..6a4c761 100644 --- a/src/test/java/test/it/utils/ItRunContext.java +++ b/src/test/java/test/it/utils/ItRunContext.java @@ -2,17 +2,16 @@ package test.it.utils; import utils.crypto.Ed25519Util; -import java.security.SecureRandom; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; -import java.util.Locale; - /** - * Глобальный контекст интеграционного прогона (один запуск Gradle test). + * Глобальный контекст IT прогона (одна JVM). * - * ВАЖНО: - * - инициализируется ровно один раз на весь процесс JVM; - * - хранит случайный login + blockchainName + ключи, чтобы все IT тесты работали в одной "сессии данных". + * ТЕПЕРЬ: + * - login берётся из TestConfig.LOGIN() + * - blockchainName = TestConfig.BCH_NAME() + * - ключи генерятся ДЕТЕРМИНИРОВАННО из login (как ты хотела) + * + * ПЛЮС: + * - тесты можно запускать по одному — initIfNeeded() вызовется автоматически. */ public final class ItRunContext { @@ -30,25 +29,16 @@ public final class ItRunContext { private ItRunContext() {} - public static void initOnce() { + /** Инициализировать, если ещё не инициализировано. */ + public static void initIfNeeded() { if (inited) return; synchronized (LOCK) { if (inited) return; - // 1) Генерим читаемый суффикс по времени + случайности, чтобы не было коллизий. - String ts = LocalDateTime.now() - .format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss", Locale.ROOT)); + login = TestConfig.LOGIN(); + blockchainName = TestConfig.BCH_NAME(); - String rnd = randomBase32(6).toLowerCase(Locale.ROOT); - - // login должен быть валидным по твоим правилам (латиница + цифры + _) - // Пример: it_20251226_173012_ab12cd - login = "it_" + ts + "_" + rnd; - - // 2) blockchainName по правилу: login + 4 цифры - blockchainName = login + TestConfig.BCH_SUFFIX_3; - - // 3) Генерация ключей ИЗ login (как ты попросила) + // 1) Генерация ключей ИЗ login // loginKey: приватный ключ = SHA-256(login) loginPrivKey = Ed25519Util.generatePrivateKeyFromString(login); loginPubKey = Ed25519Util.derivePublicKey(loginPrivKey); @@ -61,7 +51,7 @@ public final class ItRunContext { inited = true; System.out.println(TestColors.C + "\n============================================================" + TestColors.R); - System.out.println(TestColors.C + "IT ПРОГОН: сгенерированы случайные данные" + TestColors.R); + System.out.println(TestColors.C + "IT CONTEXT INIT: фиксированные данные из TestConfig" + TestColors.R); System.out.println(TestColors.C + "============================================================" + TestColors.R); System.out.println("login = " + login); System.out.println("blockchainName = " + blockchainName); @@ -72,51 +62,35 @@ public final class ItRunContext { } public static String login() { - ensureInit(); + initIfNeeded(); return login; } public static String blockchainName() { - ensureInit(); + initIfNeeded(); return blockchainName; } public static byte[] loginPrivKey() { - ensureInit(); + initIfNeeded(); return loginPrivKey.clone(); } public static byte[] loginPubKey() { - ensureInit(); + initIfNeeded(); return loginPubKey.clone(); } public static byte[] devicePrivKey() { - ensureInit(); + initIfNeeded(); return devicePrivKey.clone(); } public static byte[] devicePubKey() { - ensureInit(); + initIfNeeded(); return devicePubKey.clone(); } - private static void ensureInit() { - if (!inited) { - throw new IllegalStateException("ItRunContext ещё не инициализирован. Он должен быть вызван до тестов (через RussianSummaryListener)."); - } - } - - private static String randomBase32(int len) { - final String alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; - SecureRandom r = new SecureRandom(); - StringBuilder sb = new StringBuilder(len); - for (int i = 0; i < len; i++) { - sb.append(alphabet.charAt(r.nextInt(alphabet.length()))); - } - return sb.toString(); - } - private static String bytesToHexShort(byte[] b) { if (b == null) return "null"; StringBuilder sb = new StringBuilder(); diff --git a/src/test/java/test/it/utils/JsonBuilders.java b/src/test/java/test/it/utils/JsonBuilders.java index 0428514..f2a8059 100644 --- a/src/test/java/test/it/utils/JsonBuilders.java +++ b/src/test/java/test/it/utils/JsonBuilders.java @@ -23,8 +23,8 @@ public final class JsonBuilders { } """.formatted( requestId, - TestConfig.TEST_LOGIN(), - TestConfig.TEST_BCH_NAME(), + TestConfig.LOGIN(), + TestConfig.BCH_NAME(), TestConfig.LOGIN_PUBKEY_B64(), TestConfig.DEVICE_PUBKEY_B64(), TestConfig.TEST_BCH_LIMIT @@ -38,7 +38,7 @@ public final class JsonBuilders { "requestId": "%s", "payload": { "login": "%s" } } - """.formatted(requestId, TestConfig.TEST_LOGIN()); + """.formatted(requestId, TestConfig.LOGIN()); } public static String createAuthSession(String requestId, String authNonce, String storagePwd) { diff --git a/src/test/java/test/it/utils/RussianSummaryListener.java b/src/test/java/test/it/utils/RussianSummaryListener.java deleted file mode 100644 index c54f825..0000000 --- a/src/test/java/test/it/utils/RussianSummaryListener.java +++ /dev/null @@ -1,90 +0,0 @@ -package test.it.utils; - -import org.junit.platform.launcher.TestExecutionListener; -import org.junit.platform.launcher.TestIdentifier; -import org.junit.platform.launcher.TestPlan; -import org.junit.platform.engine.TestExecutionResult; - - -import java.util.ArrayList; -import java.util.List; - -/** - * Слушатель JUnit, который в конце прогона печатает "главный отчёт" по-русски. - * - * Подключается через src/test/resources/junit-platform.properties - */ -public class RussianSummaryListener implements TestExecutionListener { - - private final List failed = new ArrayList<>(); - private int total = 0; - private int passed = 0; - private int skipped = 0; - - @Override - public void testPlanExecutionStarted(TestPlan testPlan) { - // Инициализируем данные прогона прямо тут, чтобы гарантированно до тестов - ItRunContext.initOnce(); - } - - @Override - public void executionFinished(TestIdentifier testIdentifier, TestExecutionResult testExecutionResult) { - if (!testIdentifier.isTest()) return; - - total++; - - switch (testExecutionResult.getStatus()) { - case SUCCESSFUL -> passed++; - case ABORTED -> skipped++; - case FAILED -> { - String name = prettyName(testIdentifier); - String msg = testExecutionResult.getThrowable() - .map(t -> t.getClass().getSimpleName() + ": " + safeMsg(t.getMessage())) - .orElse("Причина неизвестна"); - failed.add(name + " — " + msg); - } - } - } - - @Override - public void testPlanExecutionFinished(TestPlan testPlan) { - System.out.println(TestColors.C + "\n============================================================" + TestColors.R); - System.out.println(TestColors.C + "ГЛАВНЫЙ ОТЧЁТ IT ПРОГОНА" + TestColors.R); - System.out.println(TestColors.C + "============================================================" + TestColors.R); - - System.out.println("Всего тестов: " + total); - System.out.println(TestColors.G + "Прошло: " + passed + TestColors.R); - System.out.println(TestColors.Y + "Пропущено: " + skipped + TestColors.R); - System.out.println(TestColors.RED + "Упало: " + failed.size() + TestColors.R); - - if (failed.isEmpty()) { - System.out.println(TestColors.G + "\n✅ ВСЕ ТЕСТЫ ПРОШЛИ УСПЕШНО" + TestColors.R); - } else { - System.out.println(TestColors.RED + "\n❌ СПИСОК УПАВШИХ ТЕСТОВ:" + TestColors.R); - for (int i = 0; i < failed.size(); i++) { - System.out.println(TestColors.RED + (i + 1) + ") " + failed.get(i) + TestColors.R); - } - } - - System.out.println(TestColors.C + "------------------------------------------------------------" + TestColors.R); - System.out.println("login = " + ItRunContext.login()); - System.out.println("blockchainName = " + ItRunContext.blockchainName()); - System.out.println(TestColors.C + "============================================================\n" + TestColors.R); - } - - private static String prettyName(TestIdentifier id) { - // displayName обычно типа: "addUser_shouldReturn200_orAlreadyExists()" - // Добавим класс, если удастся вытащить из legacyId (обычно там есть полное имя) - String dn = id.getDisplayName(); - String legacy = id.getLegacyReportingName(); // часто содержит "test.it.IT_01_AddUser" - if (legacy != null && !legacy.isBlank()) { - return legacy + " :: " + (dn == null ? "test" : dn); - } - return dn == null ? "test" : dn; - } - - private static String safeMsg(String s) { - if (s == null) return ""; - return s.replace("\n", " ").replace("\r", " "); - } -} \ No newline at end of file diff --git a/src/test/java/test/it/utils/TestConfig.java b/src/test/java/test/it/utils/TestConfig.java index 724a5e1..f364c9b 100644 --- a/src/test/java/test/it/utils/TestConfig.java +++ b/src/test/java/test/it/utils/TestConfig.java @@ -5,8 +5,15 @@ import java.util.Base64; /** * Конфиг для IT тестов. * + * ЛОГИКА: + * - login по умолчанию берём из DEFAULT_LOGIN + * - можно переопределить запуском: + * -Dit.login=anya24 + * -Dit.bchSuffix=001 + * * ВАЖНО: - * - login/blockchainName/ключи берём из ItRunContext (случайные на каждый прогон). + * - ключи/имя блокчейна вычисляются из login (через ItRunContext). + * - тесты можно запускать по отдельности, ItRunContext сам инициализируется при первом обращении. */ public final class TestConfig { @@ -15,8 +22,11 @@ public final class TestConfig { // Твой WS URI public static final String WS_URI = "ws://localhost:7070/ws"; + // ======= По умолчанию (можно поменять под свою среду) ======= + public static final String DEFAULT_LOGIN = "anya24"; + // Суффикс блокчейна по твоему правилу: login + 3 цифры - public static final String BCH_SUFFIX_3 = "001"; + public static final String DEFAULT_BCH_SUFFIX_3 = "001"; // Лимит блокчейна для AddUser public static final long TEST_BCH_LIMIT = 50_000_000L; @@ -24,14 +34,26 @@ public final class TestConfig { // Любая строка клиента (для логов) public static final String TEST_CLIENT_INFO = "it-tests"; - public static String TEST_LOGIN() { - return ItRunContext.login(); + // Папка данных (которую будет чистить IT_RunAllMain) + public static final String DATA_DIR = "data"; + + /** login для прогона (по умолчанию DEFAULT_LOGIN, можно переопределить -Dit.login=...). */ + public static String LOGIN() { + return System.getProperty("it.login", DEFAULT_LOGIN); } - public static String TEST_BCH_NAME() { - return ItRunContext.blockchainName(); + /** Суффикс для имени блокчейна (по умолчанию DEFAULT_BCH_SUFFIX_3, можно переопределить -Dit.bchSuffix=...). */ + public static String BCH_SUFFIX_3() { + return System.getProperty("it.bchSuffix", DEFAULT_BCH_SUFFIX_3); } + /** blockchainName по правилу: login + суффикс. */ + public static String BCH_NAME() { + return LOGIN() + BCH_SUFFIX_3(); + } + + // ======= Ключи (берём из ItRunContext) ======= + public static byte[] LOGIN_PRIV_KEY() { return ItRunContext.loginPrivKey(); } @@ -60,4 +82,4 @@ public final class TestConfig { public static String fakeStoragePwd() { return "pwd-" + System.nanoTime(); } -} +} \ No newline at end of file diff --git a/src/test/java/test/it/ws/IT_03_AddBlock_NoAuth.java b/src/test/java/test/it/ws/IT_03_AddBlock_NoAuth.java index bf76e12..90b7874 100644 --- a/src/test/java/test/it/ws/IT_03_AddBlock_NoAuth.java +++ b/src/test/java/test/it/ws/IT_03_AddBlock_NoAuth.java @@ -3,9 +3,11 @@ package test.it.ws; import blockchain.BchBlockEntry; import blockchain.BchCryptoVerifier; import blockchain.body.HeaderBody; +import blockchain.body.ReactionBody; import blockchain.body.TextBody; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import test.it.utils.ItRunContext; import test.it.utils.JsonBuilders; import test.it.utils.JsonParsers; import test.it.utils.TestConfig; @@ -22,30 +24,45 @@ import static org.junit.jupiter.api.Assertions.*; /** * IT_03_AddBlock_NoAuth * - * Интеграционный тест добавления блоков в персональный блокчейн без отдельной авторизации, - * в формате твоих IT-тестов (ANSI, шаги, WsTestClient). + * Интеграционный тест добавления блоков в персональный блокчейн без отдельной авторизации. * - * Сценарий: - * 1) AddBlock: HEADER (global=0, prevGlobalHash=ZERO64) -> ожидаем 200 - * - забираем payload.serverLastGlobalHash - * 2) AddBlock: TEXT (global=1, prevGlobalHash=serverLastGlobalHash) -> ожидаем 200 + * Сценарий (как ты попросил): + * 1) AddBlock: HEADER (global=0, line=0, lineNum=0, prevGlobalHash=ZERO64) -> 200 + * 2) AddBlock: TEXT#1 (global=1, line=1, lineNum=1, prevGlobalHash=hash(0)) -> 200 + * 3) AddBlock: TEXT#2 (global=2, line=1, lineNum=2, prevGlobalHash=hash(1)) -> 200 + * 4) AddBlock: TEXT#3 (global=3, line=1, lineNum=3, prevGlobalHash=hash(2)) -> 200 + * 5) AddBlock: REACT#1 (global=4, line=2, lineNum=1, prevGlobalHash=hash(3)) -> 200 + * - реакция на TEXT#1 (toBchName, toGlobal=1, toHash=hash(TEXT#1)) * - * Примечание: - * - lastLineHash пока равен lastGlobalHash. - * - подпись блока делаем ключом логина (loginPrivKey). + * Важно по линиям (твоя договорённость): + * - line 0: нулевой блок (HEADER) один на весь блокчейн (глобальный 0) + * - line 1 и line 2: первый блок каждой линии ссылается prevLineHash на hash(нулевого блока) + * + * В этом тесте мы ведём 2 массива: + * - lineLastNumber[line] — сколько блоков в линии (то есть последний lineNum) + * - lineLastHashHex[line] — hash последнего блока линии (HEX64) */ public class IT_03_AddBlock_NoAuth { // ANSI цвета private static final String R = "\u001B[0m"; private static final String G = "\u001B[32m"; - private static final String Y = "\u001B[33m"; private static final String RED = "\u001B[31m"; private static final String C = "\u001B[36m"; private static final byte[] ZERO32 = new byte[32]; private static final String ZERO64 = "0".repeat(64); + private static final short LINE_HEADER = 0; + private static final short LINE_TEXT = 1; + private static final short LINE_REACT = 2; + + public static void main(String[] args) { + ItRunContext.initIfNeeded(); + ensureUserExists(); + new IT_03_AddBlock_NoAuth().addBlock_shouldAppendHeaderThenTextThenReaction(); + } + private static void line() { System.out.println(C + "------------------------------------------------------------" + R); } @@ -95,6 +112,8 @@ public class IT_03_AddBlock_NoAuth { @BeforeAll static void ensureUserExists() { + ItRunContext.initIfNeeded(); + title("AddBlockIT (BeforeAll): предусловие — пользователь должен существовать (AddUser: 200 или 409)"); try (WsTestClient client = new WsTestClient(TestConfig.WS_URI)) { @@ -125,67 +144,261 @@ public class IT_03_AddBlock_NoAuth { } @Test - void addBlock_shouldAppendHeaderThenText() { - title("AddBlockIT: добавить HEADER(0) и затем TEXT(1) без auth — с проверкой serverLastGlobalHash"); + void addBlock_shouldAppendHeaderThenTextThenReaction() { + ItRunContext.initIfNeeded(); + + title("AddBlockIT: HEADER(0) + TEXT(1,2,3) + REACT(4->text1) без auth"); + System.out.println("Используем:"); + System.out.println(" login = " + TestConfig.LOGIN()); + System.out.println(" blockchainName = " + TestConfig.BCH_NAME()); System.out.println("Ожидание:"); - System.out.println(" 1) AddBlock HEADER (global=0, prev=ZERO64) -> 200"); - System.out.println(" - в ответе payload.serverLastGlobalHash (64 hex)"); - System.out.println(" 2) AddBlock TEXT (global=1, prev=serverLastGlobalHash) -> 200\n"); + System.out.println(" 1) HEADER (global=0, line=0, lineNum=0, prevGlobal=ZERO64) -> 200"); + System.out.println(" 2) TEXT#1 (global=1, line=1, lineNum=1, prevGlobal=hash0, prevLine=hash0) -> 200"); + System.out.println(" 3) TEXT#2 (global=2, line=1, lineNum=2, prevGlobal=hash1, prevLine=hash1) -> 200"); + System.out.println(" 4) TEXT#3 (global=3, line=1, lineNum=3, prevGlobal=hash2, prevLine=hash2) -> 200"); + System.out.println(" 5) REACT#1 (global=4, line=2, lineNum=1, prevGlobal=hash3, prevLine=hash0) -> 200 (to TEXT#1)\n"); try (WsTestClient client = new WsTestClient(TestConfig.WS_URI)) { - // -------------------- ШАГ 1: HEADER (global=0) -------------------- - stepTitle("ШАГ 1: AddBlock HEADER (global=0)"); + // ============================ + // Локальное состояние теста + // ============================ + int[] lineLastNumber = new int[8]; + String[] lineLastHashHex = new String[8]; + for (int i = 0; i < 8; i++) lineLastHashHex[i] = ""; - byte[] headerFull = buildHeaderBlockFullBytes( + int globalLastNumber = -1; + String globalLastHashHex = ZERO64; + + byte[] headerHash32 = null; // понадобится как prevLineHash для первых блоков линий 1/2 + + // ========================================================= + // ШАГ 1: HEADER (global=0, line=0, lineNum=0) + // ========================================================= + stepTitle("ШАГ 1: AddBlock HEADER (global=0, line=0, lineNum=0)"); + + BuiltBlock header = buildHeaderBlock( 0, - (short) 0, + LINE_HEADER, 0, - ZERO32, - ZERO32 + ZERO32, // prevGlobalHash32 + ZERO32 // prevLineHash32 ); String reqId1 = "it03-add-header"; - String reqJson1 = buildAddBlockJson(reqId1, TestConfig.TEST_BCH_NAME(), 0, ZERO64, base64(headerFull)); + String reqJson1 = buildAddBlockJson(reqId1, TestConfig.BCH_NAME(), 0, ZERO64, base64(header.fullBytes)); - send("AddBlock#HEADER", reqJson1); + send("AddBlock(" + reqId1 + ")", reqJson1); String resp1 = client.request(reqId1, reqJson1, Duration.ofSeconds(8)); - recv("AddBlock#HEADER", resp1); + recv("AddBlock(" + reqId1 + ")", resp1); - assert200("AddBlock#HEADER", resp1); + assert200("AddBlock(" + reqId1 + ")", resp1); - String serverLastGlobalHash = extractPayloadString(resp1, "serverLastGlobalHash"); - assertNotNull(serverLastGlobalHash, "HEADER: payload.serverLastGlobalHash must not be null"); - assertFalse(serverLastGlobalHash.isBlank(), "HEADER: payload.serverLastGlobalHash must not be blank"); - assertEquals(64, serverLastGlobalHash.trim().length(), "HEADER: serverLastGlobalHash must be 64 hex chars"); + String serverLastGlobalHash0 = extractPayloadString(resp1, "serverLastGlobalHash"); + assertNotNull(serverLastGlobalHash0, "HEADER: payload.serverLastGlobalHash must not be null"); + assertEquals(64, serverLastGlobalHash0.trim().length(), "HEADER: serverLastGlobalHash must be 64 hex chars"); - ok("HEADER принят. serverLastGlobalHash=" + serverLastGlobalHash); + String localHash0 = bytesToHex64(header.hash32); + assertEquals(localHash0, serverLastGlobalHash0, "HEADER: serverLastGlobalHash должен совпасть с локальным hash"); - // -------------------- ШАГ 2: TEXT (global=1) -------------------- - stepTitle("ШАГ 2: AddBlock TEXT (global=1)"); + // обновляем локальное состояние + headerHash32 = header.hash32; + globalLastNumber = 0; + globalLastHashHex = localHash0; - byte[] prevGlobal32 = hexToBytes32(serverLastGlobalHash); - byte[] prevLine32 = prevGlobal32; + lineLastNumber[0] = 0; + lineLastHashHex[0] = localHash0; - byte[] textFull = buildTextBlockFullBytes( + ok("HEADER принят. serverLastGlobalHash=" + serverLastGlobalHash0); + + // ========================================================= + // Общая проверка: headerHash32 уже есть + // ========================================================= + assertNotNull(headerHash32, "internal: headerHash32 must be set after header step"); + + // ========================================================= + // ШАГ 2: TEXT#1 (global=1, line=1, lineNum=1) + // prevLineHash для первого блока линии = hash(нулевого блока) + // ========================================================= + stepTitle("ШАГ 2: AddBlock TEXT#1 (global=1, line=1, lineNum=1)"); + + int text1LineNum = nextLineNum(lineLastNumber, LINE_TEXT); + byte[] prevLineHashText1 = prevLineHash32(lineLastNumber, lineLastHashHex, headerHash32, LINE_TEXT); + + BuiltBlock text1 = buildTextBlock( 1, - (short) 0, - 1, - prevGlobal32, - prevLine32, - "Hello from IT_03 test" + LINE_TEXT, + text1LineNum, + hexToBytes32(globalLastHashHex), // prevGlobalHash32 + prevLineHashText1, // prevLineHash32 + "Hello #1 from IT_03 test" ); - String reqId2 = "it03-add-text"; - String reqJson2 = buildAddBlockJson(reqId2, TestConfig.TEST_BCH_NAME(), 1, serverLastGlobalHash, base64(textFull)); + String reqId2 = "it03-add-text-1"; + String reqJson2 = buildAddBlockJson(reqId2, TestConfig.BCH_NAME(), 1, globalLastHashHex, base64(text1.fullBytes)); - send("AddBlock#TEXT", reqJson2); + send("AddBlock(" + reqId2 + ")", reqJson2); String resp2 = client.request(reqId2, reqJson2, Duration.ofSeconds(8)); - recv("AddBlock#TEXT", resp2); + recv("AddBlock(" + reqId2 + ")", resp2); - assert200("AddBlock#TEXT", resp2); + assert200("AddBlock(" + reqId2 + ")", resp2); - ok("ТЕСТ ПРОЙДЕН: AddBlock HEADER(0) + TEXT(1) успешно добавлены"); + String serverLastGlobalHash1 = extractPayloadString(resp2, "serverLastGlobalHash"); + assertNotNull(serverLastGlobalHash1, "TEXT#1: payload.serverLastGlobalHash must not be null"); + assertEquals(64, serverLastGlobalHash1.trim().length(), "TEXT#1: serverLastGlobalHash must be 64 hex chars"); + + String localHash1 = bytesToHex64(text1.hash32); + assertEquals(localHash1, serverLastGlobalHash1, "TEXT#1: serverLastGlobalHash должен совпасть с локальным hash"); + + // обновляем состояние + globalLastNumber = 1; + globalLastHashHex = localHash1; + lineLastNumber[LINE_TEXT] = text1LineNum; + lineLastHashHex[LINE_TEXT] = localHash1; + + ok("TEXT#1 принят. hash1=" + serverLastGlobalHash1); + + // ========================================================= + // ШАГ 3: TEXT#2 (global=2, line=1, lineNum=2) + // prevLineHash для второго блока линии = hash(TEXT#1) + // ========================================================= + stepTitle("ШАГ 3: AddBlock TEXT#2 (global=2, line=1, lineNum=2)"); + + int text2LineNum = nextLineNum(lineLastNumber, LINE_TEXT); + byte[] prevLineHashText2 = prevLineHash32(lineLastNumber, lineLastHashHex, headerHash32, LINE_TEXT); + + BuiltBlock text2 = buildTextBlock( + 2, + LINE_TEXT, + text2LineNum, + hexToBytes32(globalLastHashHex), + prevLineHashText2, + "Hello #2 from IT_03 test" + ); + + String reqId3 = "it03-add-text-2"; + String reqJson3 = buildAddBlockJson(reqId3, TestConfig.BCH_NAME(), 2, globalLastHashHex, base64(text2.fullBytes)); + + send("AddBlock(" + reqId3 + ")", reqJson3); + String resp3 = client.request(reqId3, reqJson3, Duration.ofSeconds(8)); + recv("AddBlock(" + reqId3 + ")", resp3); + + assert200("AddBlock(" + reqId3 + ")", resp3); + + String serverLastGlobalHash2 = extractPayloadString(resp3, "serverLastGlobalHash"); + assertNotNull(serverLastGlobalHash2, "TEXT#2: payload.serverLastGlobalHash must not be null"); + assertEquals(64, serverLastGlobalHash2.trim().length(), "TEXT#2: serverLastGlobalHash must be 64 hex chars"); + + String localHash2 = bytesToHex64(text2.hash32); + assertEquals(localHash2, serverLastGlobalHash2, "TEXT#2: serverLastGlobalHash должен совпасть с локальным hash"); + + // обновляем состояние + globalLastNumber = 2; + globalLastHashHex = localHash2; + lineLastNumber[LINE_TEXT] = text2LineNum; + lineLastHashHex[LINE_TEXT] = localHash2; + + ok("TEXT#2 принят. hash2=" + serverLastGlobalHash2); + + // ========================================================= + // ШАГ 4: TEXT#3 (global=3, line=1, lineNum=3) + // prevLineHash = hash(TEXT#2) + // ========================================================= + stepTitle("ШАГ 4: AddBlock TEXT#3 (global=3, line=1, lineNum=3)"); + + int text3LineNum = nextLineNum(lineLastNumber, LINE_TEXT); + byte[] prevLineHashText3 = prevLineHash32(lineLastNumber, lineLastHashHex, headerHash32, LINE_TEXT); + + BuiltBlock text3 = buildTextBlock( + 3, + LINE_TEXT, + text3LineNum, + hexToBytes32(globalLastHashHex), + prevLineHashText3, + "Hello #3 from IT_03 test" + ); + + String reqId4 = "it03-add-text-3"; + String reqJson4 = buildAddBlockJson(reqId4, TestConfig.BCH_NAME(), 3, globalLastHashHex, base64(text3.fullBytes)); + + send("AddBlock(" + reqId4 + ")", reqJson4); + String resp4 = client.request(reqId4, reqJson4, Duration.ofSeconds(8)); + recv("AddBlock(" + reqId4 + ")", resp4); + + assert200("AddBlock(" + reqId4 + ")", resp4); + + String serverLastGlobalHash3 = extractPayloadString(resp4, "serverLastGlobalHash"); + assertNotNull(serverLastGlobalHash3, "TEXT#3: payload.serverLastGlobalHash must not be null"); + assertEquals(64, serverLastGlobalHash3.trim().length(), "TEXT#3: serverLastGlobalHash must be 64 hex chars"); + + String localHash3 = bytesToHex64(text3.hash32); + assertEquals(localHash3, serverLastGlobalHash3, "TEXT#3: serverLastGlobalHash должен совпасть с локальным hash"); + + // обновляем состояние + globalLastNumber = 3; + globalLastHashHex = localHash3; + lineLastNumber[LINE_TEXT] = text3LineNum; + lineLastHashHex[LINE_TEXT] = localHash3; + + ok("TEXT#3 принят. hash3=" + serverLastGlobalHash3); + + // ========================================================= + // ШАГ 5: REACT#1 (global=4, line=2, lineNum=1) -> на TEXT#1 + // prevLineHash для первого блока line2 = hash(нулевого блока) + // ========================================================= + stepTitle("ШАГ 5: AddBlock REACT#1 (global=4, line=2, lineNum=1) -> to TEXT#1"); + + int react1LineNum = nextLineNum(lineLastNumber, LINE_REACT); + byte[] prevLineHashReact1 = prevLineHash32(lineLastNumber, lineLastHashHex, headerHash32, LINE_REACT); + + // ссылка на TEXT#1 (global=1, hash=text1) + String text1HashHex = lineHashAtOrThrow(text1, "text1.hash32"); + + BuiltBlock react1 = buildReactionBlock( + 4, + LINE_REACT, + react1LineNum, + hexToBytes32(globalLastHashHex), + prevLineHashReact1, + 1, // reactionCode (пример: 1 = like) + TestConfig.BCH_NAME(), + 1, // toBlockGlobalNumber = 1 (TEXT#1) + text1.hash32 // toBlockHash32 = hash(TEXT#1) + ); + + String reqId5 = "it03-add-react-1"; + String reqJson5 = buildAddBlockJson(reqId5, TestConfig.BCH_NAME(), 4, globalLastHashHex, base64(react1.fullBytes)); + + send("AddBlock(" + reqId5 + ")", reqJson5); + String resp5 = client.request(reqId5, reqJson5, Duration.ofSeconds(8)); + recv("AddBlock(" + reqId5 + ")", resp5); + + assert200("AddBlock(" + reqId5 + ")", resp5); + + String serverLastGlobalHash4 = extractPayloadString(resp5, "serverLastGlobalHash"); + assertNotNull(serverLastGlobalHash4, "REACT#1: payload.serverLastGlobalHash must not be null"); + assertEquals(64, serverLastGlobalHash4.trim().length(), "REACT#1: serverLastGlobalHash must be 64 hex chars"); + + String localHash4 = bytesToHex64(react1.hash32); + assertEquals(localHash4, serverLastGlobalHash4, "REACT#1: serverLastGlobalHash должен совпасть с локальным hash"); + + // обновляем состояние + globalLastNumber = 4; + globalLastHashHex = localHash4; + lineLastNumber[LINE_REACT] = react1LineNum; + lineLastHashHex[LINE_REACT] = localHash4; + + ok("REACT#1 принят. hash4=" + serverLastGlobalHash4); + + // ========================================================= + // Итоговый контроль массивов линий + // ========================================================= + ok("ИТОГ по линиям:"); + ok(" line0: lastNum=" + lineLastNumber[0] + ", lastHash=" + lineLastHashHex[0]); + ok(" line1: lastNum=" + lineLastNumber[1] + ", lastHash=" + lineLastHashHex[1]); + ok(" line2: lastNum=" + lineLastNumber[2] + ", lastHash=" + lineLastHashHex[2]); + + ok("ТЕСТ ПРОЙДЕН: HEADER + 3xTEXT(line1) + 1xREACT(line2) успешно добавлены и согласованы по globalHash/lineHash"); } catch (AssertionError | RuntimeException e) { boom("ТЕСТ УПАЛ: AddBlockIT. Причина: " + e.getMessage()); @@ -193,28 +406,81 @@ public class IT_03_AddBlock_NoAuth { } } + // ================================================================================= + // LINE HELPERS + // ================================================================================= + + /** Следующий lineNum: если в линии было N блоков, новый будет N+1 (для line>0). Для line0 в этом тесте только 0. */ + private static int nextLineNum(int[] lineLastNumber, short lineIndex) { + if (lineIndex < 0 || lineIndex > 7) throw new IllegalArgumentException("lineIndex must be 0..7"); + if (lineIndex == 0) return 0; // у нас header фиксированно line0/num0 + return lineLastNumber[lineIndex] + 1; + } + + /** + * prevLineHash32 по твоему правилу: + * - для первого блока линии (lineLastNumber[line]==0): prevLineHash = hash(нулевого блока) + * - иначе: prevLineHash = hash последнего блока этой линии + * + * Важно: для line0 здесь не используем (header имеет prevLine=ZERO32). + */ + private static byte[] prevLineHash32(int[] lineLastNumber, String[] lineLastHashHex, byte[] headerHash32, short lineIndex) { + if (lineIndex < 0 || lineIndex > 7) throw new IllegalArgumentException("lineIndex must be 0..7"); + if (lineIndex == 0) return ZERO32; + + if (lineLastNumber[lineIndex] == 0) { + // первый блок линии -> от нулевого блока + if (headerHash32 == null || headerHash32.length != 32) { + throw new IllegalStateException("headerHash32 is not set but required for first block of line " + lineIndex); + } + return headerHash32; + } + + String lastHex = lineLastHashHex[lineIndex]; + if (lastHex == null || lastHex.isBlank()) { + throw new IllegalStateException("lineLastHashHex[" + lineIndex + "] is blank but lineLastNumber>0"); + } + return hexToBytes32(lastHex); + } + + private static String lineHashAtOrThrow(BuiltBlock b, String name) { + if (b == null || b.hash32 == null || b.hash32.length != 32) throw new IllegalArgumentException(name + " must be 32 bytes"); + return bytesToHex64(b.hash32); + } + // ================================================================================= // BUILD BLOCKS // ================================================================================= - private static byte[] buildHeaderBlockFullBytes(int globalNumber, - short lineIndex, - int lineBlockNumber, - byte[] prevGlobalHash32, - byte[] prevLineHash32) { + /** Небольшой холдер, чтобы тест мог использовать hash32 как prevGlobal/prevLine и как toBlockHash. */ + private static final class BuiltBlock { + final byte[] fullBytes; + final byte[] hash32; - HeaderBody body = new HeaderBody(TestConfig.TEST_LOGIN()); + BuiltBlock(byte[] fullBytes, byte[] hash32) { + this.fullBytes = fullBytes; + this.hash32 = hash32; + } + } + + private static BuiltBlock buildHeaderBlock(int globalNumber, + short lineIndex, + int lineBlockNumber, + byte[] prevGlobalHash32, + byte[] prevLineHash32) { + + HeaderBody body = new HeaderBody(TestConfig.LOGIN()); byte[] bodyBytes = body.toBytes(); return buildSignedBlockFullBytes(globalNumber, lineIndex, lineBlockNumber, bodyBytes, prevGlobalHash32, prevLineHash32); } - private static byte[] buildTextBlockFullBytes(int globalNumber, - short lineIndex, - int lineBlockNumber, - byte[] prevGlobalHash32, - byte[] prevLineHash32, - String text) { + private static BuiltBlock buildTextBlock(int globalNumber, + short lineIndex, + int lineBlockNumber, + byte[] prevGlobalHash32, + byte[] prevLineHash32, + String text) { TextBody body = new TextBody(text); byte[] bodyBytes = body.toBytes(); @@ -222,12 +488,34 @@ public class IT_03_AddBlock_NoAuth { return buildSignedBlockFullBytes(globalNumber, lineIndex, lineBlockNumber, bodyBytes, prevGlobalHash32, prevLineHash32); } - private static byte[] buildSignedBlockFullBytes(int globalNumber, - short lineIndex, - int lineBlockNumber, - byte[] bodyBytes, - byte[] prevGlobalHash32, - byte[] prevLineHash32) { + private static BuiltBlock buildReactionBlock(int globalNumber, + short lineIndex, + int lineBlockNumber, + byte[] prevGlobalHash32, + byte[] prevLineHash32, + int reactionCode, + String toBlockchainName, + int toBlockGlobalNumber, + byte[] toBlockHash32) { + + ReactionBody body = new ReactionBody( + reactionCode, + toBlockchainName, + toBlockGlobalNumber, + toBlockHash32 // [32] сырые 32 байта, как ты утвердил + ); + + byte[] bodyBytes = body.toBytes(); + + return buildSignedBlockFullBytes(globalNumber, lineIndex, lineBlockNumber, bodyBytes, prevGlobalHash32, prevLineHash32); + } + + private static BuiltBlock buildSignedBlockFullBytes(int globalNumber, + short lineIndex, + int lineBlockNumber, + byte[] bodyBytes, + byte[] prevGlobalHash32, + byte[] prevLineHash32) { long ts = System.currentTimeMillis() / 1000L; @@ -243,8 +531,11 @@ public class IT_03_AddBlock_NoAuth { .put(bodyBytes) .array(); + // Ключевой момент: preimage должен совпасть с серверным правилом. + // Сервер НЕ получает prevLineHash по сети — он берёт его из своего состояния линии. + // Поэтому в тесте мы обязаны передавать сюда ровно тот же prevLineHash32 (см. prevLineHash32()). byte[] preimage = BchCryptoVerifier.buildPreimage( - TestConfig.TEST_LOGIN(), + TestConfig.LOGIN(), prevGlobalHash32, prevLineHash32, rawBytes @@ -252,10 +543,9 @@ public class IT_03_AddBlock_NoAuth { byte[] hash32 = BchCryptoVerifier.sha256(preimage); - // Подпись делаем ключом логина byte[] signature64 = Ed25519Util.sign(hash32, TestConfig.LOGIN_PRIV_KEY()); - return new BchBlockEntry( + byte[] full = new BchBlockEntry( globalNumber, ts, lineIndex, @@ -264,8 +554,14 @@ public class IT_03_AddBlock_NoAuth { signature64, hash32 ).toBytes(); + + return new BuiltBlock(full, hash32); } + // ================================================================================= + // JSON HELPERS + // ================================================================================= + private static String buildAddBlockJson(String requestId, String blockchainName, int globalNumber, @@ -301,6 +597,10 @@ public class IT_03_AddBlock_NoAuth { return Base64.getEncoder().encodeToString(bytes); } + // ================================================================================= + // HEX HELPERS + // ================================================================================= + private static byte[] hexToBytes32(String hex) { if (hex == null) throw new IllegalArgumentException("hex is null"); String s = hex.trim(); @@ -314,4 +614,16 @@ public class IT_03_AddBlock_NoAuth { } return out; } + + private static String bytesToHex64(byte[] b32) { + if (b32 == null || b32.length != 32) throw new IllegalArgumentException("b32 must be 32 bytes"); + char[] out = new char[64]; + final char[] HEX = "0123456789abcdef".toCharArray(); + for (int i = 0; i < 32; i++) { + int v = b32[i] & 0xFF; + out[i * 2] = HEX[v >>> 4]; + out[i * 2 + 1] = HEX[v & 0x0F]; + } + return new String(out); + } } \ No newline at end of file