SHA256
2952 lines
113 KiB
Plaintext
2952 lines
113 KiB
Plaintext
package test.it.blockchain;
|
||
|
||
import blockchain.BchBlockEntry;
|
||
import blockchain.body.*;
|
||
import test.it.utils.TestConfig;
|
||
import test.it.utils.TestIds;
|
||
import test.it.utils.json.JsonParsers;
|
||
import test.it.utils.log.TestLog;
|
||
import test.it.utils.ws.WsSession;
|
||
|
||
import java.time.Duration;
|
||
import java.util.Base64;
|
||
|
||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||
|
||
/**
|
||
* AddBlockSender — под новый формат BchBlockEntry (Frame v0):
|
||
* - blockBytes = preimage + sigMarker(2) + signature64
|
||
* - preimage начинается с frameCode(2) = 0x0000
|
||
* - hash32 вычисляется как sha256(preimage)
|
||
* - signature = Ed25519.sign(hash32)
|
||
*
|
||
* ВАЖНО:
|
||
* - Линии по ТЗ ведём на стороне сервера/БД (триггеры), а в тестах считаем локально.
|
||
*/
|
||
public final class AddBlockSender {
|
||
|
||
private static final String ZERO64 = "0".repeat(64);
|
||
|
||
private final WsSession ws;
|
||
private final ChainState state;
|
||
|
||
private final String login;
|
||
private final String blockchainName;
|
||
private final byte[] loginPrivKey;
|
||
|
||
public AddBlockSender(WsSession ws, ChainState state, String login, String blockchainName, byte[] loginPrivKey) {
|
||
this.ws = ws;
|
||
this.state = state;
|
||
this.login = login;
|
||
this.blockchainName = blockchainName;
|
||
this.loginPrivKey = (loginPrivKey == null ? null : loginPrivKey.clone());
|
||
if (this.ws == null) throw new IllegalArgumentException("ws == null");
|
||
if (this.state == null) throw new IllegalArgumentException("state == null");
|
||
if (this.loginPrivKey == null) throw new IllegalArgumentException("loginPrivKey == null");
|
||
}
|
||
|
||
public ChainState state() { return state; }
|
||
|
||
public void send(BodyRecord body, Duration timeout) {
|
||
if (body == null) throw new IllegalArgumentException("body == null");
|
||
|
||
body.check();
|
||
|
||
boolean isHeader = (body instanceof HeaderBody);
|
||
|
||
if (isHeader) {
|
||
if (state.lastBlockNumber() != -1) {
|
||
throw new IllegalStateException("HEADER должен быть первым: lastBlockNumber уже " + state.lastBlockNumber());
|
||
}
|
||
} else {
|
||
if (!state.hasHeader()) {
|
||
throw new IllegalStateException("Нельзя слать блоки до HEADER (нет headerHash32)");
|
||
}
|
||
}
|
||
|
||
int blockNumber = state.nextBlockNumber();
|
||
byte[] prevHash32 = state.prevHash32ForNext();
|
||
long tsSec = System.currentTimeMillis() / 1000L;
|
||
|
||
short type = typeOf(body);
|
||
short subType = subTypeOf(body);
|
||
short version = versionOf(body);
|
||
|
||
byte[] bodyBytes = body.toBytes();
|
||
|
||
// ВАЖНО: preimage должен быть БАЙТ-В-БАЙТ таким же, как в BchBlockEntry
|
||
byte[] preimage = buildPreimage(prevHash32, blockNumber, tsSec, type, subType, version, bodyBytes);
|
||
|
||
byte[] hash32 = blockchain.BchCryptoVerifier.sha256(preimage);
|
||
byte[] signature64 = utils.crypto.Ed25519Util.sign(hash32, loginPrivKey);
|
||
|
||
BchBlockEntry entry = new BchBlockEntry(
|
||
prevHash32,
|
||
blockNumber,
|
||
tsSec,
|
||
type,
|
||
subType,
|
||
version,
|
||
bodyBytes,
|
||
signature64
|
||
);
|
||
|
||
String prevHashHexForReq = (blockNumber == 0) ? ZERO64 : state.lastBlockHashHex();
|
||
|
||
String reqJson = buildAddBlockJson(blockchainName, blockNumber, prevHashHexForReq, base64(entry.toBytes()));
|
||
String op = "AddBlock(user=" + login + ", block=" + blockNumber + ", type=" + (type & 0xFFFF) + ", sub=" + (subType & 0xFFFF) + ")";
|
||
|
||
String resp = ws.call(op, reqJson, timeout);
|
||
|
||
assert200(op, resp);
|
||
|
||
String serverLastHash = JsonMini.extractPayloadString(resp, "serverLastBlockHash");
|
||
if (serverLastHash == null) {
|
||
serverLastHash = JsonMini.extractPayloadString(resp, "serverLastGlobalHash");
|
||
}
|
||
|
||
assertNotNull(serverLastHash, op + ": payload.serverLastBlockHash must not be null");
|
||
assertEquals(64, serverLastHash.trim().length(), op + ": serverLastBlockHash must be 64 hex chars");
|
||
|
||
String localHashHex = bytesToHex64(entry.getHash32());
|
||
|
||
if (TestConfig.DEBUG()) {
|
||
TestLog.info(op + ": localHash=" + localHashHex);
|
||
TestLog.info(op + ": serverLastBlockHash=" + serverLastHash);
|
||
}
|
||
|
||
assertEquals(localHashHex, serverLastHash, op + ": serverLastBlockHash must match local hash");
|
||
|
||
state.applyAppendedBlock(blockNumber, entry.getHash32(), isHeader, type, body);
|
||
|
||
if (TestConfig.DEBUG()) TestLog.info(op + ": state updated");
|
||
}
|
||
|
||
// ---------- request JSON ----------
|
||
|
||
private static String buildAddBlockJson(String blockchainName, int blockNumber, String prevBlockHashHex, String blockBytesB64) {
|
||
String requestId = TestIds.next("addblock");
|
||
return """
|
||
{
|
||
"op": "AddBlock",
|
||
"requestId": "%s",
|
||
"payload": {
|
||
"blockchainName": "%s",
|
||
"blockNumber": %d,
|
||
"prevBlockHash": "%s",
|
||
"blockBytesB64": "%s"
|
||
}
|
||
}
|
||
""".formatted(requestId, blockchainName, blockNumber, prevBlockHashHex, blockBytesB64);
|
||
}
|
||
|
||
private static void assert200(String op, String resp) {
|
||
int st = JsonParsers.status(resp);
|
||
assertEquals(200, st, op + ": expected status=200, but got=" + st + ", resp=" + resp);
|
||
TestLog.ok(op + ": status=200");
|
||
}
|
||
|
||
private static String base64(byte[] bytes) {
|
||
return Base64.getEncoder().encodeToString(bytes);
|
||
}
|
||
|
||
private static String bytesToHex64(byte[] b32) {
|
||
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);
|
||
}
|
||
|
||
// ---------- header extraction from body ----------
|
||
|
||
private static short typeOf(BodyRecord body) {
|
||
if (body instanceof HeaderBody) return HeaderBody.TYPE;
|
||
if (body instanceof CreateChannelBody) return CreateChannelBody.TYPE;
|
||
if (body instanceof TextBody) return TextBody.TYPE;
|
||
if (body instanceof ReactionBody) return ReactionBody.TYPE;
|
||
if (body instanceof ConnectionBody) return ConnectionBody.TYPE;
|
||
if (body instanceof UserParamBody) return UserParamBody.TYPE;
|
||
throw new IllegalArgumentException("Unknown body class: " + body.getClass());
|
||
}
|
||
|
||
private static short subTypeOf(BodyRecord body) {
|
||
if (body instanceof HeaderBody hb) return hb.subType;
|
||
if (body instanceof CreateChannelBody cb) return cb.subType;
|
||
if (body instanceof TextBody tb) return tb.subType;
|
||
if (body instanceof ReactionBody rb) return rb.subType;
|
||
if (body instanceof ConnectionBody cb) return cb.subType;
|
||
if (body instanceof UserParamBody ub) return ub.subType;
|
||
throw new IllegalArgumentException("Unknown body class: " + body.getClass());
|
||
}
|
||
|
||
private static short versionOf(BodyRecord body) {
|
||
if (body instanceof HeaderBody hb) return hb.version;
|
||
if (body instanceof CreateChannelBody cb) return cb.version;
|
||
if (body instanceof TextBody tb) return tb.version;
|
||
if (body instanceof ReactionBody rb) return rb.version;
|
||
if (body instanceof ConnectionBody cb) return cb.version;
|
||
if (body instanceof UserParamBody ub) return ub.version;
|
||
throw new IllegalArgumentException("Unknown body class: " + body.getClass());
|
||
}
|
||
|
||
// ---------- preimage builder (строго по BchBlockEntry Frame v0) ----------
|
||
|
||
private static byte[] buildPreimage(byte[] prevHash32,
|
||
int blockNumber,
|
||
long tsSec,
|
||
short type,
|
||
short subType,
|
||
short version,
|
||
byte[] bodyBytes) {
|
||
|
||
if (prevHash32 == null || prevHash32.length != 32) {
|
||
throw new IllegalArgumentException("prevHash32 must be 32 bytes");
|
||
}
|
||
|
||
int bodyLen = (bodyBytes == null ? 0 : bodyBytes.length);
|
||
int blockSize = BchBlockEntry.PREIMAGE_HEADER_SIZE + bodyLen;
|
||
|
||
java.nio.ByteBuffer bb = java.nio.ByteBuffer.allocate(blockSize).order(java.nio.ByteOrder.BIG_ENDIAN);
|
||
|
||
// [2] frameCode (v0)
|
||
bb.putShort((short) (BchBlockEntry.FRAME_CODE_V0 & 0xFFFF));
|
||
|
||
// [32] prevHash32
|
||
bb.put(prevHash32);
|
||
|
||
// [4] blockSize (preimage size)
|
||
bb.putInt(blockSize);
|
||
|
||
// [4] blockNumber
|
||
bb.putInt(blockNumber);
|
||
|
||
// [8] timestamp
|
||
bb.putLong(tsSec);
|
||
|
||
// [2][2][2] type/subType/version
|
||
bb.putShort(type);
|
||
bb.putShort(subType);
|
||
bb.putShort(version);
|
||
|
||
// [N] bodyBytes
|
||
if (bodyBytes != null) bb.put(bodyBytes);
|
||
|
||
return bb.array();
|
||
}
|
||
}
|
||
package test.it.blockchain;
|
||
|
||
import blockchain.body.BodyRecord;
|
||
import blockchain.body.BodyHasLine;
|
||
import blockchain.body.CreateChannelBody;
|
||
import blockchain.body.TextBody;
|
||
|
||
import java.util.HashMap;
|
||
import java.util.Map;
|
||
|
||
/**
|
||
* ChainState — состояние глобальной цепочки + состояние линий.
|
||
*
|
||
* Глобальная цепочка:
|
||
* - lastBlockNumber / lastBlockHashHex
|
||
* - map blockNumber -> hash32
|
||
*
|
||
* Линии:
|
||
* - TECH (type=0): только CREATE_CHANNEL (hasLine), root = HEADER
|
||
* - TEXT (type=1): линии каналов, root = HEADER (канал "0") или CREATE_CHANNEL (канал "X")
|
||
* - CONNECTION (type=3): одна линия
|
||
* - USER_PARAM (type=4): одна линия
|
||
*
|
||
* ВАЖНО:
|
||
* - prevLineNumber — это GLOBAL blockNumber предыдущего блока линии.
|
||
* - thisLineNumber — внутренний номер линии (для постов: 0,1,2...; для тех-линии: 1,2,3...)
|
||
* - lineCode — код линии:
|
||
* * 0 для канала "0" и для "простых" линий (connection/user_param/tech)
|
||
* * для каналов !=0: lineCode = blockNumber "заглавия" канала (CREATE_CHANNEL)
|
||
*/
|
||
public final class ChainState {
|
||
|
||
public static final short TYPE_TECH = 0; // header/create_channel
|
||
public static final short TYPE_TEXT = 1;
|
||
public static final short TYPE_REACTION = 2;
|
||
public static final short TYPE_CONNECTION = 3;
|
||
public static final short TYPE_USER_PARAM = 4;
|
||
|
||
private static final byte[] ZERO32 = new byte[32];
|
||
private static final String ZERO64 = "0".repeat(64);
|
||
|
||
// global chain
|
||
private int lastBlockNumber = -1;
|
||
private String lastBlockHashHex = ZERO64;
|
||
|
||
// header (block#0)
|
||
private byte[] headerHash32 = null;
|
||
|
||
private final Map<Integer, byte[]> hash32ByNumber = new HashMap<>();
|
||
|
||
// ---------- TECH line state ----------
|
||
private static final class TechLineState {
|
||
int lastGlobalNumber = -1; // последний TECH-блок (HEADER или CREATE_CHANNEL)
|
||
String lastHashHex = "";
|
||
int lastThisLineNumber = 0; // 0 у HEADER (логически), дальше 1,2,3...
|
||
|
||
void reset() {
|
||
lastGlobalNumber = -1;
|
||
lastHashHex = "";
|
||
lastThisLineNumber = 0;
|
||
}
|
||
}
|
||
|
||
private final TechLineState techLine = new TechLineState();
|
||
|
||
// ---------- CONNECTION/USER_PARAM line state ----------
|
||
private static final class SimpleLineState {
|
||
int lastGlobalNumber = -1;
|
||
String lastHashHex = "";
|
||
int lastThisLineNumber = 0;
|
||
|
||
void reset() {
|
||
lastGlobalNumber = -1;
|
||
lastHashHex = "";
|
||
lastThisLineNumber = 0;
|
||
}
|
||
}
|
||
|
||
private final SimpleLineState connectionLine = new SimpleLineState();
|
||
private final SimpleLineState userParamLine = new SimpleLineState();
|
||
|
||
// ---------- TEXT channels ----------
|
||
public static final class ChannelLineState {
|
||
final int lineCode; // для каналов: = rootBlockNumber; для канала 0: 0
|
||
final int rootBlockNumber; // 0 для канала 0, иначе blockNumber CREATE_CHANNEL
|
||
final String rootHashHex;
|
||
|
||
int lastGlobalNumber;
|
||
String lastHashHex;
|
||
int lastThisLineNumber; // перед первым постом = -1, чтобы первый был 0
|
||
|
||
ChannelLineState(int lineCode, int rootBlockNumber, String rootHashHex) {
|
||
this.lineCode = lineCode;
|
||
this.rootBlockNumber = rootBlockNumber;
|
||
this.rootHashHex = rootHashHex;
|
||
this.lastGlobalNumber = rootBlockNumber;
|
||
this.lastHashHex = rootHashHex;
|
||
this.lastThisLineNumber = -1;
|
||
}
|
||
}
|
||
|
||
// lineCode -> state (для канала 0 lineCode=0)
|
||
private final Map<Integer, ChannelLineState> textChannels = new HashMap<>();
|
||
|
||
public ChainState() {
|
||
techLine.reset();
|
||
connectionLine.reset();
|
||
userParamLine.reset();
|
||
}
|
||
|
||
// -------------------- global getters --------------------
|
||
|
||
public int lastBlockNumber() { return lastBlockNumber; }
|
||
public String lastBlockHashHex() { return lastBlockHashHex; }
|
||
|
||
public boolean hasHeader() {
|
||
return headerHash32 != null && headerHash32.length == 32 && lastBlockNumber >= 0;
|
||
}
|
||
|
||
public int nextBlockNumber() {
|
||
return lastBlockNumber + 1;
|
||
}
|
||
|
||
public byte[] prevHash32ForNext() {
|
||
if (lastBlockNumber < 0) return ZERO32;
|
||
return hexToBytes32(lastBlockHashHex);
|
||
}
|
||
|
||
public byte[] headerHash32() {
|
||
return headerHash32 == null ? null : headerHash32.clone();
|
||
}
|
||
|
||
public byte[] getHash32(int blockNumber) {
|
||
byte[] h = hash32ByNumber.get(blockNumber);
|
||
return h == null ? null : h.clone();
|
||
}
|
||
|
||
// -------------------- line helpers --------------------
|
||
|
||
public static final class NextLine {
|
||
public final int lineCode;
|
||
public final int prevLineNumber; // GLOBAL blockNumber
|
||
public final byte[] prevLineHash32; // 32 bytes
|
||
public final int thisLineNumber; // внутр. номер линии
|
||
|
||
public NextLine(int lineCode, int prevLineNumber, byte[] prevLineHash32, int thisLineNumber) {
|
||
this.lineCode = lineCode;
|
||
this.prevLineNumber = prevLineNumber;
|
||
this.prevLineHash32 = (prevLineHash32 == null ? null : prevLineHash32.clone());
|
||
this.thisLineNumber = thisLineNumber;
|
||
}
|
||
}
|
||
|
||
/** Следующие line-поля для TECH/CONNECTION/USER_PARAM. lineCode=0. */
|
||
public NextLine nextLineByType(short type) {
|
||
if (!hasHeader()) {
|
||
throw new IllegalStateException("Нельзя формировать line-поля до HEADER (нет headerHash32)");
|
||
}
|
||
|
||
int t = type & 0xFFFF;
|
||
|
||
if (t == TYPE_TECH) {
|
||
if (techLine.lastGlobalNumber == -1) {
|
||
throw new IllegalStateException("TECH line is not initialized yet");
|
||
}
|
||
return new NextLine(
|
||
0,
|
||
techLine.lastGlobalNumber,
|
||
hexToBytes32(techLine.lastHashHex),
|
||
techLine.lastThisLineNumber + 1
|
||
);
|
||
}
|
||
|
||
if (t == TYPE_CONNECTION) {
|
||
return nextSimpleLine(connectionLine);
|
||
}
|
||
if (t == TYPE_USER_PARAM) {
|
||
return nextSimpleLine(userParamLine);
|
||
}
|
||
|
||
throw new IllegalArgumentException("Type " + t + " не поддерживает nextLineByType()");
|
||
}
|
||
|
||
private NextLine nextSimpleLine(SimpleLineState ls) {
|
||
if (ls.lastGlobalNumber == -1) {
|
||
// первый блок линии ссылается на HEADER (block#0)
|
||
return new NextLine(0, 0, headerHash32.clone(), 1);
|
||
}
|
||
if (ls.lastHashHex == null || ls.lastHashHex.isBlank()) {
|
||
throw new IllegalStateException("LineState.lastHashHex пуст, но lastGlobalNumber!=-1");
|
||
}
|
||
return new NextLine(0, ls.lastGlobalNumber, hexToBytes32(ls.lastHashHex), ls.lastThisLineNumber + 1);
|
||
}
|
||
|
||
/**
|
||
* Следующие line-поля для TEXT-канала по lineCode.
|
||
* Для канала 0: lineCode=0.
|
||
* Для других каналов: lineCode = rootBlockNumber (CREATE_CHANNEL blockNumber).
|
||
*/
|
||
public NextLine nextTextLineByCode(int lineCode) {
|
||
if (!hasHeader()) throw new IllegalStateException("No HEADER");
|
||
ChannelLineState cs = textChannels.get(lineCode);
|
||
if (cs == null) throw new IllegalStateException("Unknown TEXT channel lineCode=" + lineCode);
|
||
|
||
return new NextLine(
|
||
lineCode,
|
||
cs.lastGlobalNumber,
|
||
hexToBytes32(cs.lastHashHex),
|
||
cs.lastThisLineNumber + 1
|
||
);
|
||
}
|
||
|
||
/** Старое имя — оставил для удобства: rootBlockNumber == lineCode для каналов. */
|
||
public NextLine nextTextLineByRoot(int rootBlockNumber) {
|
||
return nextTextLineByCode(rootBlockNumber);
|
||
}
|
||
|
||
/**
|
||
* Зарегистрировать новый канал TEXT:
|
||
* - lineCode = rootBlockNumber (blockNumber CREATE_CHANNEL)
|
||
* ИДЕМПОТЕНТНО: если уже зарегистрирован — ничего не делаем.
|
||
*/
|
||
public void registerTextChannelRoot(int rootBlockNumber, byte[] rootHash32) {
|
||
if (rootBlockNumber < 0) throw new IllegalArgumentException("rootBlockNumber must be >= 0");
|
||
if (rootHash32 == null || rootHash32.length != 32) throw new IllegalArgumentException("rootHash32 invalid");
|
||
|
||
if (textChannels.containsKey(rootBlockNumber)) {
|
||
return; // уже есть — не трогаем, чтобы не сбросить lastThisLineNumber и т.д.
|
||
}
|
||
|
||
int lineCode = rootBlockNumber;
|
||
textChannels.put(lineCode, new ChannelLineState(lineCode, rootBlockNumber, bytesToHex64(rootHash32)));
|
||
}
|
||
|
||
/** root/lineCode канала "0" (по умолчанию) — это HEADER block#0, lineCode=0. */
|
||
public int rootChannel0() {
|
||
return 0;
|
||
}
|
||
|
||
// -------------------- apply --------------------
|
||
|
||
public void applyAppendedBlock(int blockNumber, byte[] hash32, boolean isHeader, short type, BodyRecord body) {
|
||
if (hash32 == null || hash32.length != 32) {
|
||
throw new IllegalArgumentException("hash32 must be 32 bytes");
|
||
}
|
||
if (blockNumber != lastBlockNumber + 1) {
|
||
throw new IllegalStateException("blockNumber sequence broken: expected=" + (lastBlockNumber + 1) + " got=" + blockNumber);
|
||
}
|
||
|
||
if (isHeader) {
|
||
if (blockNumber != 0) throw new IllegalStateException("HEADER must be blockNumber=0");
|
||
headerHash32 = hash32.clone();
|
||
} else {
|
||
if (blockNumber == 0) throw new IllegalStateException("Non-header block can't be blockNumber=0");
|
||
if (headerHash32 == null) throw new IllegalStateException("Header must be sent before non-header blocks");
|
||
}
|
||
|
||
String hex64 = bytesToHex64(hash32);
|
||
|
||
lastBlockNumber = blockNumber;
|
||
lastBlockHashHex = hex64;
|
||
|
||
hash32ByNumber.put(blockNumber, hash32.clone());
|
||
|
||
// ---- init after HEADER ----
|
||
if (isHeader) {
|
||
// TECH line root = HEADER
|
||
techLine.lastGlobalNumber = 0;
|
||
techLine.lastHashHex = hex64;
|
||
techLine.lastThisLineNumber = 0;
|
||
|
||
// TEXT channel "0" root = HEADER, lineCode=0
|
||
registerTextChannelRoot(0, hash32);
|
||
|
||
return;
|
||
}
|
||
|
||
int t = type & 0xFFFF;
|
||
|
||
// ---- TECH (CREATE_CHANNEL) ----
|
||
if (t == TYPE_TECH && body instanceof CreateChannelBody ccb) {
|
||
techLine.lastGlobalNumber = blockNumber;
|
||
techLine.lastHashHex = hex64;
|
||
techLine.lastThisLineNumber = ccb.thisLineNumber;
|
||
|
||
// ВАЖНО: CREATE_CHANNEL — это root нового текстового канала:
|
||
// lineCode для этого канала = blockNumber CREATE_CHANNEL
|
||
registerTextChannelRoot(blockNumber, hash32);
|
||
|
||
return;
|
||
}
|
||
|
||
// ---- CONNECTION / USER_PARAM ----
|
||
if (t == TYPE_CONNECTION && body instanceof BodyHasLine hlc) {
|
||
connectionLine.lastGlobalNumber = blockNumber;
|
||
connectionLine.lastHashHex = hex64;
|
||
connectionLine.lastThisLineNumber = hlc.lineSeq();
|
||
return;
|
||
}
|
||
if (t == TYPE_USER_PARAM && body instanceof BodyHasLine hlu) {
|
||
userParamLine.lastGlobalNumber = blockNumber;
|
||
userParamLine.lastHashHex = hex64;
|
||
userParamLine.lastThisLineNumber = hlu.lineSeq();
|
||
return;
|
||
}
|
||
|
||
// ---- TEXT channels (POST/EDIT_POST) ----
|
||
if (t == TYPE_TEXT && body instanceof TextBody tb) {
|
||
if (tb.isLineMessage()) {
|
||
int lineCode = tb.lineCode;
|
||
|
||
ChannelLineState channel = textChannels.get(lineCode);
|
||
if (channel == null) {
|
||
throw new IllegalStateException(
|
||
"TEXT line message has unknown lineCode=" + lineCode +
|
||
" (канал не зарегистрирован; ждали CREATE_CHANNEL или HEADER)"
|
||
);
|
||
}
|
||
|
||
channel.lastGlobalNumber = blockNumber;
|
||
channel.lastHashHex = hex64;
|
||
channel.lastThisLineNumber = tb.thisLineNumber;
|
||
}
|
||
}
|
||
}
|
||
|
||
// -------------------- utils --------------------
|
||
|
||
private static byte[] hexToBytes32(String hex) {
|
||
if (hex == null) throw new IllegalArgumentException("hex is null");
|
||
String s = hex.trim();
|
||
if (s.length() != 64) throw new IllegalArgumentException("hex must be 64 chars, got " + s.length());
|
||
byte[] out = new byte[32];
|
||
for (int i = 0; i < 32; i++) {
|
||
int hi = Character.digit(s.charAt(i * 2), 16);
|
||
int lo = Character.digit(s.charAt(i * 2 + 1), 16);
|
||
if (hi < 0 || lo < 0) throw new IllegalArgumentException("bad hex at pos " + (i * 2));
|
||
out[i] = (byte) ((hi << 4) | lo);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
private static String bytesToHex64(byte[] b32) {
|
||
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);
|
||
}
|
||
}
|
||
package test.it.blockchain;
|
||
|
||
import com.fasterxml.jackson.databind.JsonNode;
|
||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||
|
||
/**
|
||
* JsonMini — маленькие утилиты, чтобы не раздувать зависимости.
|
||
*/
|
||
final class JsonMini {
|
||
private static final ObjectMapper M = new ObjectMapper();
|
||
private JsonMini() {}
|
||
|
||
static String extractPayloadString(String json, String field) {
|
||
try {
|
||
JsonNode root = M.readTree(json);
|
||
JsonNode payload = root.get("payload");
|
||
if (payload != null && payload.has(field)) {
|
||
JsonNode v = payload.get(field);
|
||
return (v == null || v.isNull()) ? null : v.asText();
|
||
}
|
||
} catch (Exception ignore) {}
|
||
return null;
|
||
}
|
||
}
|
||
package test.it.cases;
|
||
|
||
import test.it.utils.TestConfig;
|
||
import test.it.utils.json.JsonBuilders;
|
||
import test.it.utils.json.JsonParsers;
|
||
import test.it.utils.log.TestResult;
|
||
import test.it.utils.ws.WsSession;
|
||
|
||
import java.time.Duration;
|
||
import java.util.List;
|
||
|
||
import static org.junit.jupiter.api.Assertions.fail;
|
||
|
||
/**
|
||
* IT_01_AddUser
|
||
* Создаёт 3 пользователей: TestUser1/2/3 (200 OK или 409 USER_ALREADY_EXISTS).
|
||
*
|
||
* Обновление:
|
||
* - теперь AddUser может вернуть 409 не только USER_ALREADY_EXISTS,
|
||
* но и BLOCKCHAIN_ALREADY_EXISTS / BLOCKCHAIN_STATE_ALREADY_EXISTS.
|
||
* - дополнительно проверяем GetUser (status=200 всегда).
|
||
* - добавлен SearchUsers: поиск по префиксу (первые 3 символа).
|
||
*/
|
||
public class IT_01_AddUser {
|
||
|
||
public static void main(String[] args) {
|
||
String summary = run();
|
||
System.out.println(summary);
|
||
}
|
||
|
||
public static String run() {
|
||
TestResult r = new TestResult("IT_01_AddUser");
|
||
|
||
Duration t = Duration.ofSeconds(5);
|
||
|
||
try (WsSession ws = WsSession.open()) {
|
||
|
||
r.ok("AddUser USER1: " + TestConfig.LOGIN());
|
||
String resp1 = ws.call("AddUser#USER1", JsonBuilders.addUser(TestConfig.LOGIN()), t);
|
||
checkAddUser200or409(r, resp1);
|
||
checkGetUserMustExist(r, ws, TestConfig.LOGIN(), t);
|
||
|
||
r.ok("AddUser USER2: " + TestConfig.LOGIN2());
|
||
String resp2 = ws.call("AddUser#USER2", JsonBuilders.addUser(TestConfig.LOGIN2()), t);
|
||
checkAddUser200or409(r, resp2);
|
||
checkGetUserMustExist(r, ws, TestConfig.LOGIN2(), t);
|
||
|
||
r.ok("AddUser USER3: " + TestConfig.LOGIN3());
|
||
String resp3 = ws.call("AddUser#USER3", JsonBuilders.addUser(TestConfig.LOGIN3()), t);
|
||
checkAddUser200or409(r, resp3);
|
||
checkGetUserMustExist(r, ws, TestConfig.LOGIN3(), t);
|
||
|
||
// Доп: проверяем case-insensitive поиск в GetUser
|
||
String mixed = mixCase(TestConfig.LOGIN());
|
||
r.ok("GetUser case-insensitive: запрос=" + mixed + " (должен найти " + TestConfig.LOGIN() + ")");
|
||
checkGetUserMustExist(r, ws, mixed, t);
|
||
|
||
// Доп: проверяем "не существует" (но status=200)
|
||
String missing = "NoSuchUser_987654321";
|
||
r.ok("GetUser missing: " + missing);
|
||
checkGetUserMustNotExist(r, ws, missing, t);
|
||
|
||
// SearchUsers: один раз ищем по первым трём символам логина USER1
|
||
String prefix3 = first3(TestConfig.LOGIN());
|
||
String prefix3Mixed = mixCase(prefix3);
|
||
r.ok("SearchUsers: prefix(3)='" + prefix3Mixed + "' (должен вернуть список и содержать " + TestConfig.LOGIN() + ")");
|
||
checkSearchUsersMustContain(r, ws, prefix3Mixed, TestConfig.LOGIN(), t);
|
||
|
||
} catch (Throwable e) {
|
||
r.fail("IT_01_AddUser упал: " + e.getMessage());
|
||
}
|
||
|
||
return r.summaryLine();
|
||
}
|
||
|
||
private static void checkAddUser200or409(TestResult r, String resp) {
|
||
int st = JsonParsers.status(resp);
|
||
if (st == 200) {
|
||
r.ok("AddUser: status=200 (создан)");
|
||
return;
|
||
}
|
||
if (st == 409) {
|
||
String code = JsonParsers.errorCode(resp);
|
||
|
||
// раньше был только USER_ALREADY_EXISTS, теперь добавились ещё варианты
|
||
if ("USER_ALREADY_EXISTS".equals(code)) {
|
||
r.ok("AddUser: status=409 USER_ALREADY_EXISTS (уже был)");
|
||
return;
|
||
}
|
||
if ("BLOCKCHAIN_ALREADY_EXISTS".equals(code)) {
|
||
r.ok("AddUser: status=409 BLOCKCHAIN_ALREADY_EXISTS (blockchainName уже занят)");
|
||
return;
|
||
}
|
||
if ("BLOCKCHAIN_STATE_ALREADY_EXISTS".equals(code)) {
|
||
r.ok("AddUser: status=409 BLOCKCHAIN_STATE_ALREADY_EXISTS (blockchain_state уже есть)");
|
||
return;
|
||
}
|
||
|
||
r.fail("AddUser: status=409 но code=" + code + ", resp=" + resp);
|
||
fail("AddUser unexpected 409 code=" + code);
|
||
}
|
||
r.fail("AddUser: неожиданный status=" + st + ", resp=" + resp);
|
||
fail("AddUser unexpected status=" + st);
|
||
}
|
||
|
||
private static void checkGetUserMustExist(TestResult r, WsSession ws, String loginQuery, Duration t) {
|
||
String resp = ws.call("GetUser#" + loginQuery, JsonBuilders.getUser(loginQuery), t);
|
||
|
||
int st = JsonParsers.status(resp);
|
||
if (st != 200) {
|
||
r.fail("GetUser: ожидали status=200, получили " + st + ", resp=" + resp);
|
||
fail("GetUser unexpected status=" + st);
|
||
}
|
||
|
||
Boolean exists = JsonParsers.exists(resp);
|
||
if (exists == null || !exists) {
|
||
r.fail("GetUser: ожидали exists=true, resp=" + resp);
|
||
fail("GetUser expected exists=true");
|
||
}
|
||
|
||
// Проверяем, что сервер возвращает данные
|
||
String login = JsonParsers.userLogin(resp);
|
||
String blockchainName = JsonParsers.userBlockchainName(resp);
|
||
String solanaKey = JsonParsers.userSolanaKey(resp);
|
||
String blockchainKey = JsonParsers.userBlockchainKey(resp);
|
||
String deviceKey = JsonParsers.userDeviceKey(resp);
|
||
|
||
if (isBlank(login) || isBlank(blockchainName) || isBlank(solanaKey) || isBlank(blockchainKey) || isBlank(deviceKey)) {
|
||
r.fail("GetUser: exists=true, но поля пустые/неполные, resp=" + resp);
|
||
fail("GetUser returned incomplete user data");
|
||
}
|
||
|
||
// ВАЖНО:
|
||
// Поиск делается без учета регистра, но login/blockchainName должны вернуться как в БД.
|
||
// Для тех логинов, которые мы создаем в тесте, это ровно TestConfig.LOGIN*().
|
||
// Поэтому если запрос был смешанный регистр — сравниваем не с loginQuery, а с "каноничным" логином из конфига.
|
||
String canonical = canonicalLogin(loginQuery);
|
||
if (canonical != null) {
|
||
if (!login.equals(canonical)) {
|
||
r.fail("GetUser: login должен вернуться как в БД. expected=" + canonical + ", got=" + login + ", resp=" + resp);
|
||
fail("GetUser wrong login case");
|
||
}
|
||
|
||
String expectedBch = TestConfig.getBlockchainName(canonical);
|
||
if (!blockchainName.equals(expectedBch)) {
|
||
r.fail("GetUser: blockchainName должен вернуться как в БД. expected=" + expectedBch + ", got=" + blockchainName + ", resp=" + resp);
|
||
fail("GetUser wrong blockchainName");
|
||
}
|
||
|
||
// ключи должны совпадать с теми, что AddUser использует при регистрации
|
||
String expSol = TestConfig.solanaPublicKeyB64(canonical);
|
||
String expBchKey = TestConfig.blockchainPublicKeyB64(canonical);
|
||
String expDev = TestConfig.devicePublicKeyB64(canonical);
|
||
|
||
if (!solanaKey.equals(expSol)) {
|
||
r.fail("GetUser: solanaKey mismatch, resp=" + resp);
|
||
fail("GetUser solanaKey mismatch");
|
||
}
|
||
if (!blockchainKey.equals(expBchKey)) {
|
||
r.fail("GetUser: blockchainKey mismatch, resp=" + resp);
|
||
fail("GetUser blockchainKey mismatch");
|
||
}
|
||
if (!deviceKey.equals(expDev)) {
|
||
r.fail("GetUser: deviceKey mismatch, resp=" + resp);
|
||
fail("GetUser deviceKey mismatch");
|
||
}
|
||
}
|
||
|
||
r.ok("GetUser: exists=true, login=" + login + ", blockchainName=" + blockchainName);
|
||
}
|
||
|
||
private static void checkGetUserMustNotExist(TestResult r, WsSession ws, String loginQuery, Duration t) {
|
||
String resp = ws.call("GetUser#" + loginQuery, JsonBuilders.getUser(loginQuery), t);
|
||
|
||
int st = JsonParsers.status(resp);
|
||
if (st != 200) {
|
||
r.fail("GetUser(not exist): ожидали status=200, получили " + st + ", resp=" + resp);
|
||
fail("GetUser(not exist) unexpected status=" + st);
|
||
}
|
||
|
||
Boolean exists = JsonParsers.exists(resp);
|
||
if (exists == null) {
|
||
r.fail("GetUser(not exist): payload.exists отсутствует, resp=" + resp);
|
||
fail("GetUser(not exist) missing exists");
|
||
}
|
||
if (exists) {
|
||
r.fail("GetUser(not exist): ожидали exists=false, resp=" + resp);
|
||
fail("GetUser(not exist) expected exists=false");
|
||
}
|
||
|
||
r.ok("GetUser: exists=false (ok)");
|
||
}
|
||
|
||
private static void checkSearchUsersMustContain(TestResult r, WsSession ws, String prefix, String expectedLogin, Duration t) {
|
||
String resp = ws.call("SearchUsers#" + prefix, JsonBuilders.searchUsers(prefix), t);
|
||
|
||
int st = JsonParsers.status(resp);
|
||
if (st != 200) {
|
||
r.fail("SearchUsers: ожидали status=200, получили " + st + ", resp=" + resp);
|
||
fail("SearchUsers unexpected status=" + st);
|
||
}
|
||
|
||
List<String> logins = JsonParsers.searchLogins(resp);
|
||
if (logins == null || logins.isEmpty()) {
|
||
r.fail("SearchUsers: ожидали непустой список, resp=" + resp);
|
||
fail("SearchUsers expected non-empty list");
|
||
}
|
||
|
||
// ВАЖНО: ожидаемый логин должен быть в ответе в регистре БД (каноничный expectedLogin)
|
||
boolean found = false;
|
||
for (String s : logins) {
|
||
if (expectedLogin.equals(s)) {
|
||
found = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!found) {
|
||
r.fail("SearchUsers: ожидаемый логин не найден. expected=" + expectedLogin + ", got=" + logins + ", resp=" + resp);
|
||
fail("SearchUsers expected login not found");
|
||
}
|
||
|
||
r.ok("SearchUsers: ok, prefix=" + prefix + ", results=" + logins.size() + ", contains=" + expectedLogin);
|
||
}
|
||
|
||
private static String canonicalLogin(String anyCaseLogin) {
|
||
if (anyCaseLogin == null) return null;
|
||
String x = anyCaseLogin.trim();
|
||
if (x.isEmpty()) return null;
|
||
|
||
// Привязка только к нашим тестовым логинам, чтобы не гадать.
|
||
if (x.equalsIgnoreCase(TestConfig.LOGIN())) return TestConfig.LOGIN();
|
||
if (x.equalsIgnoreCase(TestConfig.LOGIN2())) return TestConfig.LOGIN2();
|
||
if (x.equalsIgnoreCase(TestConfig.LOGIN3())) return TestConfig.LOGIN3();
|
||
|
||
return null;
|
||
}
|
||
|
||
private static String mixCase(String s) {
|
||
if (s == null) return null;
|
||
String x = s.trim();
|
||
if (x.length() < 2) return x;
|
||
// простой "микс" без рандома, чтобы тест был детерминированный
|
||
return Character.toUpperCase(x.charAt(0)) + x.substring(1).toLowerCase();
|
||
}
|
||
|
||
private static String first3(String s) {
|
||
if (s == null) return "";
|
||
String x = s.trim();
|
||
if (x.length() <= 3) return x;
|
||
return x.substring(0, 3);
|
||
}
|
||
|
||
private static boolean isBlank(String s) {
|
||
return s == null || s.trim().isEmpty();
|
||
}
|
||
}
|
||
package test.it.cases;
|
||
|
||
import test.it.utils.TestConfig;
|
||
import test.it.utils.json.JsonBuilders;
|
||
import test.it.utils.json.JsonParsers;
|
||
import test.it.utils.log.TestLog;
|
||
import test.it.utils.log.TestResult;
|
||
import test.it.utils.ws.WsSession;
|
||
|
||
import java.time.Duration;
|
||
import java.util.List;
|
||
|
||
import static org.junit.jupiter.api.Assertions.*;
|
||
|
||
/**
|
||
* IT_02_Sessions (v2)
|
||
*
|
||
* Цель:
|
||
* - проверить создание/листинг/вход-в-сессию(2 шага)/close
|
||
* - и после завершения оставить в БД 3 активных сессии (S1,S2,S3)
|
||
*
|
||
* Протокол v2:
|
||
* - создание сессии: AuthChallenge -> CreateAuthSession (deviceKey подпись, + sessionPubKey)
|
||
* - вход в сессию: SessionChallenge(sessionId) -> nonce, затем SessionLogin(sessionId,time,signature(sessionKey))
|
||
* - ListSessions и CloseActiveSession доступны только в AUTH_STATUS_USER (после SessionLogin)
|
||
*/
|
||
public class IT_02_Sessions {
|
||
|
||
private static final String LOGIN = TestConfig.LOGIN();
|
||
|
||
public static void main(String[] args) {
|
||
TestLog.info("Standalone: этот тест требует заранее созданных пользователей -> сначала запускаю IT_01_AddUser");
|
||
System.out.println(IT_01_AddUser.run());
|
||
String summary = run();
|
||
System.out.println(summary);
|
||
}
|
||
|
||
public static String run() {
|
||
TestResult r = new TestResult("IT_02_Sessions(v2)");
|
||
|
||
Duration t = Duration.ofSeconds(5);
|
||
|
||
Session s1, s2, s3;
|
||
|
||
try {
|
||
// 1) Создаём 3 сессии (каждая — отдельным соединением)
|
||
s1 = createSession(LOGIN, t, r, "S1");
|
||
s2 = createSession(LOGIN, t, r, "S2");
|
||
s3 = createSession(LOGIN, t, r, "S3");
|
||
|
||
// 2) Входим в S1 (2 шага) и делаем ListSessions (AUTH_STATUS_USER) — должны быть S1,S2,S3
|
||
try (WsSession ws = WsSession.open()) {
|
||
sessionLogin2Steps(ws, s1, t, "Login(S1)", r);
|
||
|
||
String listResp = ws.call("ListSessions(AUTH_STATUS_USER)", JsonBuilders.listSessions(0L, ""), t);
|
||
assertEquals(200, JsonParsers.status(listResp), "ListSessions(AUTH_STATUS_USER) must be 200");
|
||
|
||
List<String> ids = JsonParsers.sessionIds(listResp);
|
||
r.ok("ListSessions(AUTH_STATUS_USER): " + ids);
|
||
|
||
assertTrue(ids.contains(s1.sessionId), "Must contain S1");
|
||
assertTrue(ids.contains(s2.sessionId), "Must contain S2");
|
||
assertTrue(ids.contains(s3.sessionId), "Must contain S3");
|
||
r.ok("Проверка OK: список содержит S1,S2,S3");
|
||
}
|
||
|
||
// 3) Проверяем CloseActiveSession так, чтобы итогом всё равно осталось 3 сессии:
|
||
// создаём TEMP, логинимся в S1, закрываем TEMP, убеждаемся что S1,S2,S3 остались.
|
||
Session temp = createSession(LOGIN, t, r, "TEMP");
|
||
|
||
try (WsSession ws = WsSession.open()) {
|
||
sessionLogin2Steps(ws, s1, t, "Login(S1) for close", r);
|
||
|
||
String closeResp = ws.call("CloseActiveSession(TEMP)", JsonBuilders.closeActiveSession(temp.sessionId, 0L, ""), t);
|
||
assertEquals(200, JsonParsers.status(closeResp), "CloseActiveSession(TEMP) must be 200");
|
||
r.ok("CloseActiveSession(TEMP): OK");
|
||
}
|
||
|
||
// 4) Финальная проверка: снова логинимся в S1 и ListSessions => S1,S2,S3 должны остаться, TEMP нет
|
||
try (WsSession ws = WsSession.open()) {
|
||
sessionLogin2Steps(ws, s1, t, "Final Login(S1)", r);
|
||
|
||
String listResp = ws.call("ListSessions(final)", JsonBuilders.listSessions(0L, ""), t);
|
||
assertEquals(200, JsonParsers.status(listResp));
|
||
|
||
List<String> ids = JsonParsers.sessionIds(listResp);
|
||
r.ok("Final ListSessions: " + ids);
|
||
|
||
assertTrue(ids.contains(s1.sessionId));
|
||
assertTrue(ids.contains(s2.sessionId));
|
||
assertTrue(ids.contains(s3.sessionId));
|
||
assertFalse(ids.contains(temp.sessionId));
|
||
r.ok("ИТОГ OK: после теста в БД остались 3 активные сессии (S1,S2,S3)");
|
||
}
|
||
|
||
} catch (Throwable e) {
|
||
r.fail("IT_02_Sessions(v2) упал: " + e.getMessage());
|
||
}
|
||
|
||
return r.summaryLine();
|
||
}
|
||
|
||
private static Session createSession(String login, Duration t, TestResult r, String label) {
|
||
try (WsSession ws = WsSession.open()) {
|
||
|
||
// шаг 1: AuthChallenge
|
||
String nonceResp = ws.call("AuthChallenge(" + label + ")", JsonBuilders.authChallenge(login), t);
|
||
assertEquals(200, JsonParsers.status(nonceResp), "AuthChallenge(" + label + ") must be 200");
|
||
String authNonce = JsonParsers.authNonce(nonceResp);
|
||
assertNotNull(authNonce, "authNonce must not be null for " + label);
|
||
|
||
// для тестов: sessionKey = deviceKey (в реале будет отдельный keypair)
|
||
String sessionPubKeyB64 = TestConfig.devicePublicKeyB64(login);
|
||
|
||
// storagePwd на клиенте (сохраняем, чтобы потом проверить, что сервер вернул именно его)
|
||
String storagePwd = TestConfig.fakeStoragePwd();
|
||
|
||
// шаг 2: CreateAuthSession (device подпись + sessionPubKey)
|
||
String createResp = ws.call(
|
||
"CreateAuthSession(" + label + ")",
|
||
JsonBuilders.createAuthSessionV2(login, authNonce, storagePwd, sessionPubKeyB64),
|
||
t
|
||
);
|
||
assertEquals(200, JsonParsers.status(createResp), "CreateAuthSession(" + label + ") must be 200");
|
||
|
||
String sid = JsonParsers.sessionId(createResp);
|
||
assertNotNull(sid, "sessionId must not be null");
|
||
|
||
r.ok("Создана сессия " + label + ": sessionId=" + sid);
|
||
|
||
// для тестов используем devicePriv как sessionPriv
|
||
byte[] sessionPrivKey = TestConfig.getDevicePrivatKey(login);
|
||
|
||
return new Session(sid, sessionPrivKey, storagePwd);
|
||
}
|
||
}
|
||
|
||
private static void sessionLogin2Steps(WsSession ws, Session s, Duration t, String label, TestResult r) {
|
||
// шаг 1: SessionChallenge(sessionId)
|
||
String chResp = ws.call("SessionChallenge " + label, JsonBuilders.sessionChallenge(s.sessionId), t);
|
||
assertEquals(200, JsonParsers.status(chResp), "SessionChallenge must be 200");
|
||
String nonce = JsonParsers.sessionNonce(chResp);
|
||
assertNotNull(nonce, "SessionChallenge nonce must not be null");
|
||
|
||
// шаг 2: SessionLogin(sessionId, timeMs, signature(sessionKey, SESSION_LOGIN:...))
|
||
String loginResp = ws.call("SessionLogin " + label, JsonBuilders.sessionLogin(s.sessionId, nonce, s.sessionPrivKey), t);
|
||
assertEquals(200, JsonParsers.status(loginResp), "SessionLogin must be 200");
|
||
|
||
String storagePwd = JsonParsers.storagePwd(loginResp);
|
||
assertNotNull(storagePwd, "storagePwd must not be null after SessionLogin");
|
||
assertEquals(s.storagePwd, storagePwd, "storagePwd must match what client provided on CreateAuthSession");
|
||
|
||
r.ok(label + ": SessionLogin OK, storagePwd verified");
|
||
}
|
||
|
||
private record Session(String sessionId, byte[] sessionPrivKey, String storagePwd) {}
|
||
}
|
||
package test.it.cases;
|
||
|
||
import blockchain.MsgSubType;
|
||
import blockchain.body.ConnectionBody;
|
||
import blockchain.body.CreateChannelBody;
|
||
import blockchain.body.HeaderBody;
|
||
import blockchain.body.TextBody;
|
||
import test.it.blockchain.AddBlockSender;
|
||
import test.it.blockchain.ChainState;
|
||
import test.it.utils.TestConfig;
|
||
import test.it.utils.log.TestLog;
|
||
import test.it.utils.log.TestResult;
|
||
import test.it.utils.ws.WsSession;
|
||
|
||
import java.time.Duration;
|
||
|
||
import static org.junit.jupiter.api.Assertions.*;
|
||
|
||
/**
|
||
* IT_03_AddBlock_NoAuth — сценарий блоков (новый формат + каналы + связи).
|
||
*
|
||
* CONNECTION (type=3):
|
||
* - всегда имеет hasLine (lineCode+prevLineNumber+prevLineHash32+thisLineNumber)
|
||
* - всегда имеет target:
|
||
* toBlockchainName + toBlockGlobalNumber + toBlockHash32
|
||
*
|
||
* Правило target для связей/подписок:
|
||
* - FRIEND/CONTACT -> target = HEADER цели (blockNumber=0)
|
||
* - FOLLOW пользователя -> target = HEADER цели (blockNumber=0)
|
||
* - FOLLOW канала -> target = ROOT канала:
|
||
* канал "0" -> HEADER (0)
|
||
* канал "X" -> CREATE_CHANNEL (blockNumber create_channel)
|
||
*/
|
||
public class IT_03_AddBlock_NoAuth {
|
||
|
||
public static void main(String[] args) {
|
||
TestLog.info("Standalone: этот тест требует заранее созданных пользователей -> запускаю IT_01_AddUser");
|
||
System.out.println(IT_01_AddUser.run());
|
||
String summary = run();
|
||
System.out.println(summary);
|
||
}
|
||
|
||
public static String run() {
|
||
TestResult r = new TestResult("IT_03_AddBlock_NoAuth");
|
||
|
||
String u1 = TestConfig.LOGIN();
|
||
String u2 = TestConfig.LOGIN2();
|
||
String u3 = TestConfig.LOGIN3();
|
||
|
||
String bch1 = TestConfig.getBlockchainName(u1);
|
||
String bch2 = TestConfig.getBlockchainName(u2);
|
||
String bch3 = TestConfig.getBlockchainName(u3);
|
||
|
||
Duration t = Duration.ofSeconds(1);
|
||
|
||
try (WsSession ws = WsSession.open()) {
|
||
|
||
if (TestConfig.DEBUG()) {
|
||
TestLog.titleBlock(
|
||
"IT_03:\n" +
|
||
" USER1=" + u1 + " bch=" + bch1 + "\n" +
|
||
" USER2=" + u2 + " bch=" + bch2 + "\n" +
|
||
" USER3=" + u3 + " bch=" + bch3 + "\n" +
|
||
"\nСценарий: каналы + кросс-чейн reply + connections (follow/friend/contact/uncontact)."
|
||
);
|
||
}
|
||
|
||
// =========================
|
||
// USER1
|
||
// =========================
|
||
ChainState st1 = new ChainState();
|
||
AddBlockSender sender1 = new AddBlockSender(ws, st1, u1, bch1, TestConfig.getBlockchainPrivatKey(u1));
|
||
|
||
sender1.send(new HeaderBody(u1), t);
|
||
assertTrue(st1.hasHeader());
|
||
|
||
int u1HeaderBlock = 0;
|
||
byte[] u1HeaderHash = st1.getHash32(u1HeaderBlock);
|
||
assertNotNull(u1HeaderHash);
|
||
|
||
// канал "0" root = HEADER (0)
|
||
int root0 = st1.rootChannel0();
|
||
|
||
// POST в канал "0"
|
||
{
|
||
var ln = st1.nextTextLineByRoot(root0);
|
||
sender1.send(new TextBody(
|
||
MsgSubType.TEXT_POST,
|
||
root0,
|
||
ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
|
||
"U1: story/post in channel 0",
|
||
null, null, null
|
||
), t);
|
||
}
|
||
|
||
int post0Block = st1.lastBlockNumber();
|
||
byte[] post0Hash = st1.getHash32(post0Block);
|
||
assertNotNull(post0Hash);
|
||
|
||
// CREATE_CHANNEL "News" (TECH line)
|
||
int newsRootBlock;
|
||
byte[] newsRootHash;
|
||
{
|
||
var ln = st1.nextLineByType(ChainState.TYPE_TECH);
|
||
sender1.send(new CreateChannelBody(
|
||
0, // lineCode TECH
|
||
ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
|
||
"News"
|
||
), t);
|
||
|
||
newsRootBlock = st1.lastBlockNumber(); // root канала = blockNumber этого CREATE_CHANNEL
|
||
newsRootHash = st1.getHash32(newsRootBlock);
|
||
assertNotNull(newsRootHash);
|
||
|
||
st1.registerTextChannelRoot(newsRootBlock, newsRootHash);
|
||
}
|
||
|
||
// POST #0 в канал "News"
|
||
int newsPost0Block;
|
||
byte[] newsPost0Hash;
|
||
{
|
||
var ln = st1.nextTextLineByRoot(newsRootBlock);
|
||
sender1.send(new TextBody(
|
||
MsgSubType.TEXT_POST,
|
||
newsRootBlock,
|
||
ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
|
||
"U1: News post #0",
|
||
null, null, null
|
||
), t);
|
||
|
||
newsPost0Block = st1.lastBlockNumber();
|
||
newsPost0Hash = st1.getHash32(newsPost0Block);
|
||
assertNotNull(newsPost0Hash);
|
||
}
|
||
|
||
// POST #1 в канал "News"
|
||
{
|
||
var ln = st1.nextTextLineByRoot(newsRootBlock);
|
||
sender1.send(new TextBody(
|
||
MsgSubType.TEXT_POST,
|
||
newsRootBlock,
|
||
ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
|
||
"U1: News post #1",
|
||
null, null, null
|
||
), t);
|
||
}
|
||
|
||
// EDIT_POST (в линии канала) -> target на ОРИГИНАЛЬНЫЙ POST (без toBlockchainName)
|
||
{
|
||
var ln = st1.nextTextLineByRoot(newsRootBlock);
|
||
sender1.send(new TextBody(
|
||
MsgSubType.TEXT_EDIT_POST,
|
||
newsRootBlock,
|
||
ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
|
||
"U1: News post #0 (EDIT)",
|
||
null,
|
||
newsPost0Block,
|
||
newsPost0Hash
|
||
), t);
|
||
}
|
||
|
||
// =========================
|
||
// USER2
|
||
// =========================
|
||
ChainState st2 = new ChainState();
|
||
AddBlockSender sender2 = new AddBlockSender(ws, st2, u2, bch2, TestConfig.getBlockchainPrivatKey(u2));
|
||
|
||
sender2.send(new HeaderBody(u2), t);
|
||
assertTrue(st2.hasHeader());
|
||
|
||
int u2HeaderBlock = 0;
|
||
byte[] u2HeaderHash = st2.getHash32(u2HeaderBlock);
|
||
assertNotNull(u2HeaderHash);
|
||
|
||
// =========================
|
||
// СВЯЗИ (CONNECTION)
|
||
// =========================
|
||
|
||
// 1) U1 подписался на U2 (FOLLOW на пользователя -> target=HEADER U2)
|
||
sendConnection(sender1, st1, MsgSubType.CONNECTION_FOLLOW,
|
||
bch2, u2HeaderBlock, u2HeaderHash,
|
||
"U1 follows U2 (target=U2 HEADER)", t);
|
||
|
||
// 2) U2 подписался на канал U1 "News" (FOLLOW на канал -> target=root CREATE_CHANNEL U1)
|
||
sendConnection(sender2, st2, MsgSubType.CONNECTION_FOLLOW,
|
||
bch1, newsRootBlock, newsRootHash,
|
||
"U2 follows U1 channel 'News' (target=U1 CREATE_CHANNEL root)", t);
|
||
|
||
// 3) FRIEND взаимно (на HEADER)
|
||
sendConnection(sender1, st1, MsgSubType.CONNECTION_FRIEND,
|
||
bch2, u2HeaderBlock, u2HeaderHash,
|
||
"U1 -> U2: FRIEND", t);
|
||
|
||
sendConnection(sender2, st2, MsgSubType.CONNECTION_FRIEND,
|
||
bch1, u1HeaderBlock, u1HeaderHash,
|
||
"U2 -> U1: FRIEND", t);
|
||
|
||
// 4) CONTACT несколько
|
||
sendConnection(sender1, st1, MsgSubType.CONNECTION_CONTACT,
|
||
bch2, u2HeaderBlock, u2HeaderHash,
|
||
"U1 -> U2: CONTACT", t);
|
||
|
||
sendConnection(sender2, st2, MsgSubType.CONNECTION_CONTACT,
|
||
bch1, u1HeaderBlock, u1HeaderHash,
|
||
"U2 -> U1: CONTACT", t);
|
||
|
||
// =========================
|
||
// USER2 REPLY (ответ в чужой канал)
|
||
// =========================
|
||
{
|
||
sender2.send(TextBody.newReply(
|
||
bch1,
|
||
newsPost0Block,
|
||
newsPost0Hash,
|
||
"U2: reply to U1 News post #0 (cross-chain)"
|
||
), t);
|
||
}
|
||
|
||
// =========================
|
||
// USER3 + доп. контакт
|
||
// =========================
|
||
ChainState st3 = new ChainState();
|
||
AddBlockSender sender3 = new AddBlockSender(ws, st3, u3, bch3, TestConfig.getBlockchainPrivatKey(u3));
|
||
|
||
sender3.send(new HeaderBody(u3), t);
|
||
assertTrue(st3.hasHeader());
|
||
|
||
int u3HeaderBlock = 0;
|
||
byte[] u3HeaderHash = st3.getHash32(u3HeaderBlock);
|
||
assertNotNull(u3HeaderHash);
|
||
|
||
// U1 -> U3: CONTACT
|
||
sendConnection(sender1, st1, MsgSubType.CONNECTION_CONTACT,
|
||
bch3, u3HeaderBlock, u3HeaderHash,
|
||
"U1 -> U3: CONTACT", t);
|
||
|
||
// 5) U1 убирает U2 из контактов (UNCONTACT)
|
||
sendConnection(sender1, st1, MsgSubType.CONNECTION_UNCONTACT,
|
||
bch2, u2HeaderBlock, u2HeaderHash,
|
||
"U1 -> U2: UNCONTACT", t);
|
||
|
||
r.ok("IT_03 сценарий блоков + connections выполнен");
|
||
|
||
} catch (Throwable e) {
|
||
r.fail("IT_03 упал: " + e.getMessage());
|
||
}
|
||
|
||
return r.summaryLine();
|
||
}
|
||
|
||
/**
|
||
* Отправка 1 блока CONNECTION.
|
||
*
|
||
* ВАЖНО: ConnectionBody НЕ содержит note в байтах.
|
||
* Если нужно “описание” — логируем отдельно.
|
||
*/
|
||
private static void sendConnection(AddBlockSender sender,
|
||
ChainState st,
|
||
short subType,
|
||
String toBlockchainName,
|
||
int toBlockNumber,
|
||
byte[] toBlockHash32,
|
||
String logNote,
|
||
Duration timeout) {
|
||
|
||
if (TestConfig.DEBUG()) {
|
||
TestLog.info("CONNECTION: subType=" + (subType & 0xFFFF)
|
||
+ " to=" + toBlockchainName
|
||
+ " targetBlock=" + toBlockNumber
|
||
+ " note=" + logNote);
|
||
}
|
||
|
||
var ln = st.nextLineByType(ChainState.TYPE_CONNECTION);
|
||
|
||
// КОНСТРУКТОР ИЗ ТВОЕГО КОДА:
|
||
// ConnectionBody(int lineCode, int prevLineNumber, byte[] prevLineHash32, int thisLineNumber,
|
||
// short subType, String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32)
|
||
sender.send(new ConnectionBody(
|
||
0, // lineCode для connection линии
|
||
ln.prevLineNumber,
|
||
ln.prevLineHash32,
|
||
ln.thisLineNumber,
|
||
subType,
|
||
toBlockchainName,
|
||
toBlockNumber,
|
||
toBlockHash32
|
||
), timeout);
|
||
}
|
||
}
|
||
package test.it.cases;
|
||
|
||
import com.fasterxml.jackson.databind.JsonNode;
|
||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||
import test.it.utils.*;
|
||
import test.it.utils.TestConfig;
|
||
import test.it.utils.json.JsonParsers;
|
||
import test.it.utils.log.TestLog;
|
||
import test.it.utils.log.TestResult;
|
||
import test.it.utils.ws.WsSession;
|
||
import utils.config.ShineSignatureConstants;
|
||
import utils.crypto.Ed25519Util;
|
||
|
||
import java.nio.charset.StandardCharsets;
|
||
import java.time.Duration;
|
||
import java.util.Base64;
|
||
|
||
import static org.junit.jupiter.api.Assertions.*;
|
||
|
||
/**
|
||
* IT_04_UserParams_NoAuth
|
||
*
|
||
* ВАЖНО:
|
||
* - пользователей НЕ создаём (их создаёт IT_01)
|
||
*/
|
||
public class IT_04_UserParams_NoAuth {
|
||
|
||
private static final ObjectMapper M = new ObjectMapper();
|
||
|
||
public static void main(String[] args) {
|
||
TestLog.info("Standalone: этот тест требует заранее созданных пользователей -> сначала запускаю IT_01_AddUser");
|
||
System.out.println(IT_01_AddUser.run());
|
||
String summary = run();
|
||
System.out.println(summary);
|
||
}
|
||
|
||
public static String run() {
|
||
TestResult r = new TestResult("IT_04_UserParams_NoAuth");
|
||
|
||
Duration timeout = Duration.ofSeconds(5);
|
||
|
||
final String login = TestConfig.LOGIN();
|
||
final String deviceKeyB64 = TestConfig.devicePublicKeyB64(login);
|
||
final byte[] devicePrivKey = TestConfig.getDevicePrivatKey(login);
|
||
|
||
try {
|
||
// 1) сохранить param1
|
||
final String p1 = "profile:name";
|
||
final String v1 = "Anna";
|
||
final long t1 = System.currentTimeMillis();
|
||
upsertUserParam_OK(r, login, p1, t1, v1, deviceKeyB64, devicePrivKey, timeout);
|
||
|
||
// 2) получить param1 и проверить
|
||
NetParam got1 = getUserParam_200(r, login, p1, timeout);
|
||
assertEquals(login, got1.login);
|
||
assertEquals(p1, got1.param);
|
||
assertEquals(t1, got1.timeMs);
|
||
assertEquals(v1, got1.value);
|
||
assertEquals(deviceKeyB64, got1.deviceKeyB64);
|
||
assertNotNull(got1.signatureB64);
|
||
assertFalse(got1.signatureB64.isBlank());
|
||
r.ok("GetUserParam(param1) OK");
|
||
|
||
// 3) сохранить param2
|
||
final String p2 = "profile:city";
|
||
final String v2 = "Amsterdam";
|
||
final long t2 = t1 + 10;
|
||
upsertUserParam_OK(r, login, p2, t2, v2, deviceKeyB64, devicePrivKey, timeout);
|
||
|
||
// 4) обновить param1
|
||
final String v1b = "Anna Updated";
|
||
final long t1b = t2 + 10;
|
||
upsertUserParam_OK(r, login, p1, t1b, v1b, deviceKeyB64, devicePrivKey, timeout);
|
||
|
||
NetParam got1b = getUserParam_200(r, login, p1, timeout);
|
||
assertEquals(t1b, got1b.timeMs);
|
||
assertEquals(v1b, got1b.value);
|
||
r.ok("GetUserParam(updated param1) OK");
|
||
|
||
// 5) list всех параметров
|
||
NetParamList list = listUserParams_200(r, login, timeout);
|
||
|
||
NetParam lp1 = list.find(p1);
|
||
NetParam lp2 = list.find(p2);
|
||
|
||
assertNotNull(lp1, "ListUserParams должен содержать param1=" + p1);
|
||
assertNotNull(lp2, "ListUserParams должен содержать param2=" + p2);
|
||
|
||
assertEquals(t1b, lp1.timeMs);
|
||
assertEquals(v1b, lp1.value);
|
||
|
||
assertEquals(t2, lp2.timeMs);
|
||
assertEquals(v2, lp2.value);
|
||
|
||
assertEquals(deviceKeyB64, lp1.deviceKeyB64);
|
||
assertEquals(deviceKeyB64, lp2.deviceKeyB64);
|
||
assertNotNull(lp1.signatureB64);
|
||
assertNotNull(lp2.signatureB64);
|
||
|
||
r.ok("ListUserParams OK");
|
||
|
||
} catch (Throwable e) {
|
||
r.fail("IT_04 упал: " + e.getMessage());
|
||
}
|
||
|
||
return r.summaryLine();
|
||
}
|
||
|
||
// =================================================================================
|
||
// WS helpers: Upsert/Get/List
|
||
// =================================================================================
|
||
|
||
private static void upsertUserParam_OK(TestResult r, String login, String param, long timeMs, String value, String deviceKeyB64, byte[] devicePrivKey, Duration timeout) {
|
||
String signatureB64 = signUserParam(devicePrivKey, login, param, timeMs, value);
|
||
|
||
String reqJson = """
|
||
{
|
||
"op": "UpsertUserParam",
|
||
"requestId": "%s",
|
||
"payload": {
|
||
"login": "%s",
|
||
"param": "%s",
|
||
"time_ms": %d,
|
||
"value": "%s",
|
||
"device_key": "%s",
|
||
"signature": "%s"
|
||
}
|
||
}
|
||
""".formatted(TestIds.next("upsert"), login, param, timeMs, jsonEscape(value), deviceKeyB64, signatureB64);
|
||
|
||
try (WsSession ws = WsSession.open()) {
|
||
String resp = ws.call("UpsertUserParam(" + param + ")", reqJson, timeout);
|
||
assertEquals(200, JsonParsers.status(resp), "UpsertUserParam expected 200, resp=" + resp);
|
||
r.ok("UpsertUserParam(" + param + "): OK");
|
||
}
|
||
}
|
||
|
||
private static NetParam getUserParam_200(TestResult r, String login, String param, Duration timeout) {
|
||
String reqJson = """
|
||
{
|
||
"op": "GetUserParam",
|
||
"requestId": "%s",
|
||
"payload": {
|
||
"login": "%s",
|
||
"param": "%s"
|
||
}
|
||
}
|
||
""".formatted(TestIds.next("getparam"), login, param);
|
||
|
||
try (WsSession ws = WsSession.open()) {
|
||
String resp = ws.call("GetUserParam(" + param + ")", reqJson, timeout);
|
||
assertEquals(200, JsonParsers.status(resp), "GetUserParam expected 200, resp=" + resp);
|
||
r.ok("GetUserParam(" + param + "): OK");
|
||
return parseParamFromResponsePayload(resp);
|
||
}
|
||
}
|
||
|
||
private static NetParamList listUserParams_200(TestResult r, String login, Duration timeout) {
|
||
String reqJson = """
|
||
{
|
||
"op": "ListUserParams",
|
||
"requestId": "%s",
|
||
"payload": { "login": "%s" }
|
||
}
|
||
""".formatted(TestIds.next("listparams"), login);
|
||
|
||
try (WsSession ws = WsSession.open()) {
|
||
String resp = ws.call("ListUserParams", reqJson, timeout);
|
||
assertEquals(200, JsonParsers.status(resp), "ListUserParams expected 200, resp=" + resp);
|
||
r.ok("ListUserParams: OK");
|
||
return parseParamListFromResponsePayload(resp);
|
||
}
|
||
}
|
||
|
||
// =================================================================================
|
||
// Parsing helpers
|
||
// =================================================================================
|
||
|
||
private static NetParam parseParamFromResponsePayload(String respJson) {
|
||
try {
|
||
JsonNode root = M.readTree(respJson);
|
||
JsonNode payload = root.get("payload");
|
||
assertNotNull(payload, "payload is null: " + respJson);
|
||
|
||
NetParam p = new NetParam();
|
||
p.login = text(payload, "login");
|
||
p.param = text(payload, "param");
|
||
p.timeMs = longVal(payload, "time_ms");
|
||
p.value = text(payload, "value");
|
||
p.deviceKeyB64 = text(payload, "device_key");
|
||
p.signatureB64 = text(payload, "signature");
|
||
return p;
|
||
} catch (Exception e) {
|
||
throw new RuntimeException("Failed to parse GetUserParam response: " + respJson, e);
|
||
}
|
||
}
|
||
|
||
private static NetParamList parseParamListFromResponsePayload(String respJson) {
|
||
try {
|
||
JsonNode root = M.readTree(respJson);
|
||
JsonNode payload = root.get("payload");
|
||
assertNotNull(payload, "payload is null: " + respJson);
|
||
|
||
NetParamList out = new NetParamList();
|
||
out.login = text(payload, "login");
|
||
|
||
JsonNode arr = payload.get("params");
|
||
assertNotNull(arr, "payload.params is null: " + respJson);
|
||
assertTrue(arr.isArray(), "payload.params must be array: " + respJson);
|
||
|
||
for (JsonNode it : arr) {
|
||
NetParam p = new NetParam();
|
||
p.login = text(it, "login");
|
||
p.param = text(it, "param");
|
||
p.timeMs = longVal(it, "time_ms");
|
||
p.value = text(it, "value");
|
||
p.deviceKeyB64 = text(it, "device_key");
|
||
p.signatureB64 = text(it, "signature");
|
||
out.items = out.itemsAppend(p);
|
||
}
|
||
return out;
|
||
} catch (Exception e) {
|
||
throw new RuntimeException("Failed to parse ListUserParams response: " + respJson, e);
|
||
}
|
||
}
|
||
|
||
private static String text(JsonNode obj, String field) {
|
||
JsonNode v = obj.get(field);
|
||
return (v == null || v.isNull()) ? null : v.asText();
|
||
}
|
||
|
||
private static long longVal(JsonNode obj, String field) {
|
||
JsonNode v = obj.get(field);
|
||
if (v == null || v.isNull()) return 0;
|
||
return v.asLong();
|
||
}
|
||
|
||
// =================================================================================
|
||
// Signature + JSON helpers
|
||
// =================================================================================
|
||
|
||
private static String signUserParam(byte[] devicePrivKey, String login, String param, long timeMs, String value) {
|
||
String signText = ShineSignatureConstants.USER_PARAMETER_PREFIX + login + param + timeMs + value;
|
||
byte[] signBytes = signText.getBytes(StandardCharsets.UTF_8);
|
||
byte[] sig64 = Ed25519Util.sign(signBytes, devicePrivKey);
|
||
return Base64.getEncoder().encodeToString(sig64);
|
||
}
|
||
|
||
private static String jsonEscape(String s) {
|
||
if (s == null) return "";
|
||
return s.replace("\\", "\\\\").replace("\"", "\\\"");
|
||
}
|
||
|
||
// =================================================================================
|
||
// DTOs
|
||
// =================================================================================
|
||
|
||
private static final class NetParam {
|
||
String login;
|
||
String param;
|
||
long timeMs;
|
||
String value;
|
||
String deviceKeyB64;
|
||
String signatureB64;
|
||
}
|
||
|
||
private static final class NetParamList {
|
||
String login;
|
||
NetParam[] items = new NetParam[0];
|
||
|
||
NetParam[] itemsAppend(NetParam p) {
|
||
NetParam[] n = new NetParam[items.length + 1];
|
||
System.arraycopy(items, 0, n, 0, items.length);
|
||
n[items.length] = p;
|
||
items = n;
|
||
return items;
|
||
}
|
||
|
||
NetParam find(String param) {
|
||
for (NetParam p : items) {
|
||
if (p != null && param.equals(p.param)) return p;
|
||
}
|
||
return null;
|
||
}
|
||
}
|
||
}
|
||
package test.it.cases;
|
||
|
||
import test.it.utils.TestConfig;
|
||
import test.it.utils.json.JsonBuilders;
|
||
import test.it.utils.json.JsonParsers;
|
||
import test.it.utils.log.TestResult;
|
||
import test.it.utils.ws.WsSession;
|
||
|
||
import java.time.Duration;
|
||
import java.util.List;
|
||
|
||
import static org.junit.jupiter.api.Assertions.fail;
|
||
|
||
/**
|
||
* IT_05_UserConnections
|
||
*
|
||
* Делает пару запросов GetFriendsLists (без проверок существования юзеров — это уже в IT_01).
|
||
*
|
||
* Ожидаемый формат ответа:
|
||
* {
|
||
* "op":"GetFriendsLists",
|
||
* "requestId":"...",
|
||
* "status":200,
|
||
* "payload":{
|
||
* "login":"TestUser1", // канонический регистр из БД
|
||
* "out_friends":[...], // кому login поставил FRIEND
|
||
* "in_friends":[...] // кто поставил FRIEND login
|
||
* }
|
||
* }
|
||
*
|
||
* ВАЖНО:
|
||
* - login в запросе может быть в любом регистре,
|
||
* - но в ответе payload.login должен быть канонический (как в БД).
|
||
*/
|
||
public class IT_05_UserConnections {
|
||
|
||
public static void main(String[] args) {
|
||
String summary = run();
|
||
System.out.println(summary);
|
||
}
|
||
|
||
public static String run() {
|
||
TestResult r = new TestResult("IT_05_UserConnections");
|
||
Duration t = Duration.ofSeconds(5);
|
||
|
||
final String u1 = TestConfig.LOGIN();
|
||
final String u2 = TestConfig.LOGIN2();
|
||
|
||
try (WsSession ws = WsSession.open()) {
|
||
|
||
// 1) Запрос списков связей для u1 (канонический регистр)
|
||
r.ok("GetFriendsLists USER1: " + u1);
|
||
String resp1 = ws.call("GetFriendsLists#U1", JsonBuilders.getFriendsLists(u1), t);
|
||
check200(r, resp1);
|
||
checkCanonicalLogin(r, resp1, u1);
|
||
checkTwoListsPresent(r, resp1);
|
||
|
||
// 2) Запрос списков связей для u1 (смешанный регистр)
|
||
String u1mixed = mixCase(u1);
|
||
r.ok("GetFriendsLists USER1 mixed-case request: " + u1mixed + " (expect login=" + u1 + ")");
|
||
String resp2 = ws.call("GetFriendsLists#U1_MIX", JsonBuilders.getFriendsLists(u1mixed), t);
|
||
check200(r, resp2);
|
||
checkCanonicalLogin(r, resp2, u1);
|
||
checkTwoListsPresent(r, resp2);
|
||
|
||
// 3) Ещё один запрос — для u2 (просто чтобы "пару запросов")
|
||
r.ok("GetFriendsLists USER2: " + u2);
|
||
String resp3 = ws.call("GetFriendsLists#U2", JsonBuilders.getFriendsLists(u2), t);
|
||
check200(r, resp3);
|
||
checkCanonicalLogin(r, resp3, u2);
|
||
checkTwoListsPresent(r, resp3);
|
||
|
||
// лог для наглядности (могут быть пустые, это ок)
|
||
List<String> out1 = JsonParsers.friendsOut(resp1);
|
||
List<String> in1 = JsonParsers.friendsIn(resp1);
|
||
|
||
r.ok("Friends lists USER1: out=" + out1.size() + ", in=" + in1.size());
|
||
|
||
} catch (Throwable e) {
|
||
r.fail("IT_05_UserConnections упал: " + e.getMessage());
|
||
}
|
||
|
||
return r.summaryLine();
|
||
}
|
||
|
||
// ================= checks =================
|
||
|
||
private static void check200(TestResult r, String resp) {
|
||
int st = JsonParsers.status(resp);
|
||
if (st != 200) {
|
||
r.fail("ожидали status=200, получили " + st + ", resp=" + resp);
|
||
fail("unexpected status=" + st);
|
||
}
|
||
}
|
||
|
||
private static void checkCanonicalLogin(TestResult r, String resp, String expectedCanonicalLogin) {
|
||
String got = JsonParsers.friendsLogin(resp);
|
||
if (got == null) {
|
||
r.fail("GetFriendsLists: payload.login отсутствует, resp=" + resp);
|
||
fail("GetFriendsLists missing payload.login");
|
||
}
|
||
if (!expectedCanonicalLogin.equals(got)) {
|
||
r.fail("GetFriendsLists: login должен вернуться канонический. expected=" + expectedCanonicalLogin + ", got=" + got + ", resp=" + resp);
|
||
fail("GetFriendsLists wrong login case");
|
||
}
|
||
}
|
||
|
||
private static void checkTwoListsPresent(TestResult r, String resp) {
|
||
// В JsonParsers.getPayloadStringArray сейчас возвращает пустой список, даже если поле отсутствует/не массив.
|
||
// Поэтому дополнительно проверяем, что парсер вернул НЕ null (он и не должен возвращать null).
|
||
List<String> out = JsonParsers.friendsOut(resp);
|
||
List<String> in = JsonParsers.friendsIn(resp);
|
||
|
||
if (out == null || in == null) {
|
||
r.fail("GetFriendsLists: out_friends/in_friends не должны быть null, resp=" + resp);
|
||
fail("GetFriendsLists lists are null");
|
||
}
|
||
|
||
// Просто отмечаем, что поля читаются, даже если пустые.
|
||
r.ok("GetFriendsLists lists present: out=" + out.size() + ", in=" + in.size());
|
||
}
|
||
|
||
private static String mixCase(String s) {
|
||
if (s == null) return null;
|
||
String x = s.trim();
|
||
if (x.length() < 2) return x;
|
||
return Character.toUpperCase(x.charAt(0)) + x.substring(1).toLowerCase();
|
||
}
|
||
}
|
||
package test.it;
|
||
|
||
import test.it.runner.IT_RunAllMain;
|
||
|
||
import java.util.Objects;
|
||
|
||
public class IT_DeployRestartAndRunRemoteMain {
|
||
|
||
// ====== НАСТРОЙКИ (можно переопределять systemProperty) ======
|
||
private static final String REMOTE_HOST = System.getProperty("it.remoteHost", "194.87.0.247");
|
||
private static final String REMOTE_USER = System.getProperty("it.remoteUser", "user");
|
||
|
||
private static final String REMOTE_DIR = System.getProperty("it.remoteDir", "/home/user/docker/shine-server");
|
||
private static final String REMOTE_JAR = REMOTE_DIR + "/shine-server.jar";
|
||
private static final String REMOTE_DATA = System.getProperty("it.remoteDataDir", REMOTE_DIR + "/data");
|
||
|
||
private static final String SERVICE_NAME = System.getProperty("it.service", "shine-server");
|
||
|
||
private static final String LOCAL_JAR = System.getProperty("it.localJar", "build/libs/shine-server.jar");
|
||
|
||
// URI для IT-тестов (переключаем на сервер)
|
||
private static final String WS_URI_SERVER = System.getProperty("it.wsUri", "wss://shineup.me/ws");
|
||
|
||
public static void main(String[] args) {
|
||
|
||
// 0) Build shadowJar локально
|
||
// shStrict("./gradlew -q shadowJar");
|
||
|
||
// 1) stop service на сервере
|
||
sshStrict("sudo systemctl stop " + SERVICE_NAME + " || true");
|
||
|
||
// 2) upload jar -> .new
|
||
scpStrict(LOCAL_JAR, REMOTE_JAR + ".new");
|
||
|
||
// 3) заменить jar атомарно
|
||
sshStrict("mv -f " + q(REMOTE_JAR + ".new") + " " + q(REMOTE_JAR));
|
||
|
||
// 4) удалить data/*
|
||
// (на всякий случай: если папки нет — создать)
|
||
sshStrict("mkdir -p " + q(REMOTE_DATA) + " && rm -rf " + q(REMOTE_DATA) + "/*");
|
||
|
||
// 5) start service
|
||
sshStrict("sudo systemctl start " + SERVICE_NAME);
|
||
|
||
// 6) дождаться поднятия (простая проверка: порт слушается)
|
||
waitRemotePort7070();
|
||
|
||
// 7) переключаем IT на серверный WS URI (без правок исходников)
|
||
System.setProperty("it.wsUri", WS_URI_SERVER);
|
||
|
||
// 8) прогон тестов
|
||
int failed = IT_RunAllMain.runAll();
|
||
System.exit(failed);
|
||
}
|
||
|
||
private static void waitRemotePort7070() {
|
||
for (int i = 0; i < 50; i++) {
|
||
int code = ssh("ss -ltnp | grep -q ':7070'"); // 0 если найдено
|
||
if (code == 0) return;
|
||
sleepMs(200);
|
||
}
|
||
throw new RuntimeException("Remote port 7070 did not start in time on " + REMOTE_HOST);
|
||
}
|
||
|
||
// ---------- helpers ----------
|
||
private static void shStrict(String cmd) {
|
||
int code = sh(cmd);
|
||
if (code != 0) throw new RuntimeException("Command failed (" + code + "): " + cmd);
|
||
}
|
||
|
||
private static void sshStrict(String remoteCmd) {
|
||
int code = ssh(remoteCmd);
|
||
if (code != 0) throw new RuntimeException("SSH command failed (" + code + "): " + remoteCmd);
|
||
}
|
||
|
||
private static int ssh(String remoteCmd) {
|
||
String cmd = "ssh " + REMOTE_USER + "@" + REMOTE_HOST + " " + q("bash -lc " + q(remoteCmd));
|
||
return sh(cmd);
|
||
}
|
||
|
||
private static void scpStrict(String local, String remote) {
|
||
Objects.requireNonNull(local);
|
||
Objects.requireNonNull(remote);
|
||
int code = sh("scp -p " + q(local) + " " + REMOTE_USER + "@" + REMOTE_HOST + ":" + q(remote));
|
||
if (code != 0) throw new RuntimeException("SCP failed (" + code + ")");
|
||
}
|
||
|
||
private static int sh(String cmd) {
|
||
try {
|
||
Process p = new ProcessBuilder("bash", "-lc", cmd).inheritIO().start();
|
||
return p.waitFor();
|
||
} catch (Exception e) {
|
||
throw new RuntimeException("Command error: " + cmd, e);
|
||
}
|
||
}
|
||
|
||
private static String q(String s) {
|
||
// простая одинарная кавычка для bash
|
||
return "'" + s.replace("'", "'\"'\"'") + "'";
|
||
}
|
||
|
||
private static void sleepMs(long ms) {
|
||
try { Thread.sleep(ms); }
|
||
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
|
||
}
|
||
}
|
||
package test.it;
|
||
|
||
import server.ws.WsServer;
|
||
import test.it.runner.IT_CleanAllDate;
|
||
import test.it.runner.IT_RunAllMain;
|
||
|
||
public class IT_RunAllCleanStartWsMain {
|
||
|
||
public static void main(String[] args) {
|
||
runBash("kill -9 $(lsof -t -i:7070) 2>/dev/null || true");
|
||
|
||
IT_CleanAllDate.main(new String[0]);
|
||
|
||
Thread wsThread = new Thread(() -> {
|
||
try {
|
||
WsServer.main(new String[0]);
|
||
} catch (Throwable t) {
|
||
t.printStackTrace(System.out);
|
||
}
|
||
}, "wsServer-thread");
|
||
wsThread.setDaemon(true);
|
||
wsThread.start();
|
||
|
||
sleepMs(1000);
|
||
|
||
int failed = IT_RunAllMain.runAll();
|
||
System.exit(failed);
|
||
}
|
||
|
||
private static void runBash(String cmd) {
|
||
try {
|
||
Process p = new ProcessBuilder("bash", "-lc", cmd).inheritIO().start();
|
||
p.waitFor();
|
||
} catch (Exception e) {
|
||
System.out.println("WARN: bash command failed: " + e);
|
||
}
|
||
}
|
||
|
||
private static void sleepMs(long ms) {
|
||
try {
|
||
Thread.sleep(ms);
|
||
} catch (InterruptedException ignored) {
|
||
Thread.currentThread().interrupt();
|
||
}
|
||
}
|
||
}
|
||
package test.it.runner;
|
||
|
||
import test.it.utils.TestConfig;
|
||
import test.it.utils.log.TestLog;
|
||
|
||
import java.io.IOException;
|
||
import java.nio.file.*;
|
||
import java.util.Comparator;
|
||
|
||
/**
|
||
*
|
||
* Делает:
|
||
* 1) чистит папку data/
|
||
*/
|
||
public class IT_CleanAllDate {
|
||
|
||
private static final String DATA_DIR = "data";
|
||
|
||
public static void main(String[] args) {
|
||
// ItRunContext.initIfNeeded();
|
||
|
||
TestLog.title("IT RUN CLEAN: очистка data/ + запуск всех тестов");
|
||
|
||
try {
|
||
cleanupDataDir(DATA_DIR);
|
||
} catch (Throwable t) {
|
||
TestLog.boom("Не смог очистить data/. Причина: " + t.getMessage());
|
||
if (TestConfig.DEBUG()) t.printStackTrace(System.out);
|
||
System.exit(1);
|
||
}
|
||
|
||
}
|
||
|
||
private static void cleanupDataDir(String dirName) throws IOException {
|
||
Path dir = Paths.get(dirName);
|
||
|
||
if (!Files.exists(dir)) {
|
||
TestLog.warn("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);
|
||
}
|
||
});
|
||
|
||
TestLog.ok("data очищена: " + dir.toAbsolutePath());
|
||
}
|
||
}
|
||
package test.it.runner;
|
||
|
||
import test.it.cases.IT_01_AddUser;
|
||
import test.it.cases.IT_02_Sessions;
|
||
import test.it.cases.IT_03_AddBlock_NoAuth;
|
||
import test.it.cases.IT_04_UserParams_NoAuth;
|
||
import test.it.cases.IT_05_UserConnections;
|
||
import test.it.utils.log.TestLog;
|
||
|
||
import java.util.ArrayList;
|
||
import java.util.List;
|
||
|
||
/**
|
||
* Ручной запуск всех IT тестов БЕЗ JUnit.
|
||
* Печатает итоги по каждому тесту отдельной строкой.
|
||
*/
|
||
public class IT_RunAllMain {
|
||
|
||
/**
|
||
* Настройка поведения прогона:
|
||
* - true : остановить запуск сразу после первого упавшего теста
|
||
* - false : прогнать все тесты до конца, даже если некоторые упали
|
||
*/
|
||
private static final boolean STOP_ON_FIRST_FAIL = true;
|
||
|
||
public static void main(String[] args) {
|
||
int failed = runAll();
|
||
// при желании можно вернуть код выхода ОС:
|
||
// System.exit(failed == 0 ? 0 : 1);
|
||
}
|
||
|
||
public static int runAll() {
|
||
|
||
List<String> summaries = new ArrayList<>();
|
||
int failed = 0;
|
||
|
||
TestLog.title("IT RUN: запуск всех тестов подряд"
|
||
+ (STOP_ON_FIRST_FAIL ? " (STOP_ON_FIRST_FAIL=ON)" : " (STOP_ON_FIRST_FAIL=OFF)"));
|
||
|
||
String s1 = IT_01_AddUser.run(); summaries.add(s1);
|
||
if (s1.contains("FAIL:")) { failed++; if (STOP_ON_FIRST_FAIL) return finishEarly(summaries, failed); }
|
||
|
||
String s2 = IT_02_Sessions.run(); summaries.add(s2);
|
||
if (s2.contains("FAIL:")) { failed++; if (STOP_ON_FIRST_FAIL) return finishEarly(summaries, failed); }
|
||
|
||
String s3 = IT_03_AddBlock_NoAuth.run(); summaries.add(s3);
|
||
if (s3.contains("FAIL:")) { failed++; if (STOP_ON_FIRST_FAIL) return finishEarly(summaries, failed); }
|
||
|
||
String s4 = IT_04_UserParams_NoAuth.run(); summaries.add(s4);
|
||
if (s4.contains("FAIL:")) { failed++; if (STOP_ON_FIRST_FAIL) return finishEarly(summaries, failed); }
|
||
|
||
String s5 = IT_05_UserConnections.run(); summaries.add(s5);
|
||
if (s5.contains("FAIL:")) { failed++; if (STOP_ON_FIRST_FAIL) return finishEarly(summaries, failed); }
|
||
|
||
return finish(summaries, failed);
|
||
}
|
||
|
||
private static int finishEarly(List<String> summaries, int failed) {
|
||
TestLog.boom("⛔ Остановка прогона: найден FAIL, STOP_ON_FIRST_FAIL=ON");
|
||
return finish(summaries, failed);
|
||
}
|
||
|
||
private static int finish(List<String> summaries, int failed) {
|
||
TestLog.title("IT RUN RESULT (per test)");
|
||
for (String s : summaries) System.out.println(s);
|
||
|
||
if (failed == 0) TestLog.ok("\n ВСЕ IT ТЕСТЫ УСПЕШНО ЗАВЕРШЕНЫ");
|
||
else TestLog.boom("❌ IT ПРОГОН УПАЛ: failed=" + failed + " из " + summaries.size());
|
||
|
||
return failed;
|
||
}
|
||
}
|
||
package test.it.suite;
|
||
|
||
import org.junit.platform.suite.api.SelectClasses;
|
||
import org.junit.platform.suite.api.Suite;
|
||
import test.it.cases.IT_01_AddUser;
|
||
import test.it.cases.IT_02_Sessions;
|
||
import test.it.cases.IT_03_AddBlock_NoAuth;
|
||
|
||
/**
|
||
* Сьют, который запускает IT тесты строго в заданном порядке.
|
||
*
|
||
* Запуск:
|
||
* ./gradlew test --tests test.it.suite.IT_00_Suite
|
||
*/
|
||
@Suite
|
||
@SelectClasses({
|
||
IT_01_AddUser.class,
|
||
IT_02_Sessions.class,
|
||
IT_03_AddBlock_NoAuth.class
|
||
})
|
||
public class IT_00_Suite {
|
||
// пусто
|
||
}
|
||
package test.it.utils.json;
|
||
|
||
import test.it.utils.TestIds;
|
||
import test.it.utils.TestConfig;
|
||
import utils.crypto.Ed25519Util;
|
||
|
||
import java.nio.charset.StandardCharsets;
|
||
import java.util.Base64;
|
||
|
||
/** Builder'ы JSON запросов. Внутри автоматически генерим requestId. */
|
||
public final class JsonBuilders {
|
||
private JsonBuilders() {}
|
||
|
||
// ---------------- AddUser ----------------
|
||
|
||
public static String addUser(String login) {
|
||
String requestId = TestIds.next("adduser");
|
||
String blockchainName = TestConfig.getBlockchainName(login);
|
||
|
||
String solanaKeyB64 = TestConfig.solanaPublicKeyB64(login);
|
||
String blockchainKeyB64 = TestConfig.blockchainPublicKeyB64(login);
|
||
String deviceKeyB64 = TestConfig.devicePublicKeyB64(login);
|
||
|
||
return """
|
||
{
|
||
"op": "AddUser",
|
||
"requestId": "%s",
|
||
"payload": {
|
||
"login": "%s",
|
||
"blockchainName": "%s",
|
||
"solanaKey": "%s",
|
||
"blockchainKey": "%s",
|
||
"deviceKey": "%s",
|
||
"bchLimit": %d
|
||
}
|
||
}
|
||
""".formatted(
|
||
requestId,
|
||
login,
|
||
blockchainName,
|
||
solanaKeyB64,
|
||
blockchainKeyB64,
|
||
deviceKeyB64,
|
||
TestConfig.TEST_BCH_LIMIT
|
||
);
|
||
}
|
||
|
||
// ---------------- GetUser ----------------
|
||
|
||
public static String getUser(String login) {
|
||
String requestId = TestIds.next("getuser");
|
||
return """
|
||
{
|
||
"op": "GetUser",
|
||
"requestId": "%s",
|
||
"payload": {
|
||
"login": "%s"
|
||
}
|
||
}
|
||
""".formatted(requestId, login);
|
||
}
|
||
|
||
// ---------------- SearchUsers ----------------
|
||
|
||
public static String searchUsers(String prefix) {
|
||
String requestId = TestIds.next("searchusers");
|
||
return """
|
||
{
|
||
"op": "SearchUsers",
|
||
"requestId": "%s",
|
||
"payload": {
|
||
"prefix": "%s"
|
||
}
|
||
}
|
||
""".formatted(requestId, prefix);
|
||
}
|
||
|
||
// ---------------- GetFriendsLists ----------------
|
||
|
||
public static String getFriendsLists(String login) {
|
||
String requestId = TestIds.next("friends");
|
||
return """
|
||
{
|
||
"op": "GetFriendsLists",
|
||
"requestId": "%s",
|
||
"payload": {
|
||
"login": "%s"
|
||
}
|
||
}
|
||
""".formatted(requestId, login);
|
||
}
|
||
|
||
// ---------------- AuthChallenge ----------------
|
||
|
||
public static String authChallenge(String login) {
|
||
String requestId = TestIds.next("auth");
|
||
return """
|
||
{
|
||
"op": "AuthChallenge",
|
||
"requestId": "%s",
|
||
"payload": { "login": "%s" }
|
||
}
|
||
""".formatted(requestId, login);
|
||
}
|
||
|
||
// ---------------- CreateAuthSession (v2) ----------------
|
||
// v2: sessionKey генерируется/хранится на клиенте, на сервер отправляем sessionPubKeyB64 (base64).
|
||
//
|
||
// ВАЖНО (новое правило):
|
||
// Подпись CreateAuthSession делается ТОЛЬКО deviceKey над строкой:
|
||
// preimage = "AUTH_CREATE_SESSION:" + login + ":" + timeMs + ":" + authNonce
|
||
//
|
||
// storagePwd и sessionPubKeyB64 НЕ входят в preimage.
|
||
|
||
public static String createAuthSessionV2(String login, String authNonce, String storagePwd, String sessionPubKeyB64) {
|
||
long timeMs = System.currentTimeMillis();
|
||
|
||
// подпись делаем devicePrivKey
|
||
byte[] devicePriv = TestConfig.getDevicePrivatKey(login);
|
||
String sigB64 = signAuthCreateSession(login, timeMs, authNonce, devicePriv);
|
||
|
||
String requestId = TestIds.next("create");
|
||
return """
|
||
{
|
||
"op": "CreateAuthSession",
|
||
"requestId": "%s",
|
||
"payload": {
|
||
"storagePwd": "%s",
|
||
"sessionPubKeyB64": "%s",
|
||
"timeMs": %d,
|
||
"signatureB64": "%s",
|
||
"clientInfo": "%s"
|
||
}
|
||
}
|
||
""".formatted(
|
||
requestId,
|
||
storagePwd,
|
||
sessionPubKeyB64,
|
||
timeMs,
|
||
sigB64,
|
||
TestConfig.TEST_CLIENT_INFO
|
||
);
|
||
}
|
||
|
||
// ---------------- SessionChallenge (v2) ----------------
|
||
|
||
public static String sessionChallenge(String sessionId) {
|
||
String requestId = TestIds.next("sch");
|
||
return """
|
||
{
|
||
"op": "SessionChallenge",
|
||
"requestId": "%s",
|
||
"payload": {
|
||
"sessionId": "%s"
|
||
}
|
||
}
|
||
""".formatted(requestId, sessionId);
|
||
}
|
||
|
||
// ---------------- SessionLogin (v2) ----------------
|
||
// Подпись SessionLogin по-прежнему делается sessionPrivKey:
|
||
// preimage = "SESSION_LOGIN:" + sessionId + ":" + timeMs + ":" + nonce
|
||
|
||
public static String sessionLogin(String sessionId, String nonce, byte[] sessionPrivKey) {
|
||
long timeMs = System.currentTimeMillis();
|
||
String sigB64 = signSessionLogin(sessionId, timeMs, nonce, sessionPrivKey);
|
||
|
||
String requestId = TestIds.next("slogin");
|
||
return """
|
||
{
|
||
"op": "SessionLogin",
|
||
"requestId": "%s",
|
||
"payload": {
|
||
"sessionId": "%s",
|
||
"timeMs": %d,
|
||
"signatureB64": "%s",
|
||
"clientInfo": "%s"
|
||
}
|
||
}
|
||
""".formatted(requestId, sessionId, timeMs, sigB64, TestConfig.TEST_CLIENT_INFO);
|
||
}
|
||
|
||
// ---------------- ListSessions ----------------
|
||
|
||
public static String listSessions(long timeMs, String signatureB64) {
|
||
String requestId = TestIds.next("list");
|
||
if (signatureB64 == null) signatureB64 = "";
|
||
return """
|
||
{
|
||
"op": "ListSessions",
|
||
"requestId": "%s",
|
||
"payload": {
|
||
}
|
||
}
|
||
""".formatted(requestId, timeMs, signatureB64);
|
||
}
|
||
|
||
// ---------------- CloseActiveSession ----------------
|
||
|
||
public static String closeActiveSession(String sessionId, long timeMs, String signatureB64) {
|
||
String requestId = TestIds.next("close");
|
||
if (signatureB64 == null) signatureB64 = "";
|
||
return """
|
||
{
|
||
"op": "CloseActiveSession",
|
||
"requestId": "%s",
|
||
"payload": {
|
||
"sessionId": "%s"
|
||
}
|
||
}
|
||
""".formatted(requestId, sessionId, timeMs, signatureB64);
|
||
}
|
||
|
||
// ---------------- ListSubscribedChannels ----------------
|
||
|
||
public static String listSubscribedChannels(String login) {
|
||
String requestId = TestIds.next("subs");
|
||
return """
|
||
{
|
||
"op": "ListSubscribedChannels",
|
||
"requestId": "%s",
|
||
"payload": { "login": "%s" }
|
||
}
|
||
""".formatted(requestId, login);
|
||
}
|
||
|
||
/**
|
||
* Подпись CreateAuthSession(v2):
|
||
* preimage = "AUTH_CREATE_SESSION:" + login + ":" + timeMs + ":" + authNonce
|
||
* подписываем devicePrivKey.
|
||
*/
|
||
public static String signAuthCreateSession(String login, long timeMs, String authNonce, byte[] devicePrivKey) {
|
||
String preimageStr = "AUTH_CREATE_SESSION:" + login + ":" + timeMs + ":" + authNonce;
|
||
byte[] preimage = preimageStr.getBytes(StandardCharsets.UTF_8);
|
||
byte[] sig = Ed25519Util.sign(preimage, devicePrivKey);
|
||
return Base64.getEncoder().encodeToString(sig);
|
||
}
|
||
|
||
/**
|
||
* Подпись для SessionLogin(v2):
|
||
* preimage = "SESSION_LOGIN:" + sessionId + ":" + timeMs + ":" + nonce
|
||
* подписываем sessionPrivKey.
|
||
*/
|
||
public static String signSessionLogin(String sessionId, long timeMs, String nonce, byte[] sessionPrivKey) {
|
||
String preimageStr = "SESSION_LOGIN:" + sessionId + ":" + timeMs + ":" + nonce;
|
||
byte[] preimage = preimageStr.getBytes(StandardCharsets.UTF_8);
|
||
byte[] sig = Ed25519Util.sign(preimage, sessionPrivKey);
|
||
return Base64.getEncoder().encodeToString(sig);
|
||
}
|
||
}
|
||
package test.it.utils.json;
|
||
|
||
import com.fasterxml.jackson.databind.JsonNode;
|
||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||
|
||
import java.util.ArrayList;
|
||
import java.util.List;
|
||
|
||
public final class JsonParsers {
|
||
private JsonParsers(){}
|
||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||
|
||
public static int status(String json) {
|
||
try {
|
||
JsonNode root = MAPPER.readTree(json);
|
||
return root.has("status") ? root.get("status").asInt() : -1;
|
||
} catch (Exception e) {
|
||
return -1;
|
||
}
|
||
}
|
||
|
||
public static String authNonce(String json) {
|
||
try {
|
||
JsonNode root = MAPPER.readTree(json);
|
||
JsonNode payload = root.get("payload");
|
||
if (payload != null && payload.has("authNonce")) return payload.get("authNonce").asText();
|
||
return null;
|
||
} catch (Exception e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** nonce из SessionChallenge(v2) */
|
||
public static String sessionNonce(String json) {
|
||
try {
|
||
JsonNode root = MAPPER.readTree(json);
|
||
JsonNode payload = root.get("payload");
|
||
if (payload != null && payload.has("nonce")) return payload.get("nonce").asText();
|
||
return null;
|
||
} catch (Exception e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
public static String sessionId(String json) {
|
||
try {
|
||
JsonNode root = MAPPER.readTree(json);
|
||
JsonNode payload = root.get("payload");
|
||
if (payload != null && payload.has("sessionId")) return payload.get("sessionId").asText();
|
||
return null;
|
||
} catch (Exception e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// оставляю для совместимости с другими тестами, но в IT_02(v2) больше не используется
|
||
public static String sessionPwd(String json) {
|
||
try {
|
||
JsonNode root = MAPPER.readTree(json);
|
||
JsonNode payload = root.get("payload");
|
||
if (payload != null && payload.has("sessionPwd")) return payload.get("sessionPwd").asText();
|
||
return null;
|
||
} catch (Exception e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
public static String storagePwd(String json) {
|
||
try {
|
||
JsonNode root = MAPPER.readTree(json);
|
||
JsonNode payload = root.get("payload");
|
||
if (payload != null && payload.has("storagePwd")) return payload.get("storagePwd").asText();
|
||
return null;
|
||
} catch (Exception e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
public static List<String> sessionIds(String json) {
|
||
List<String> res = new ArrayList<>();
|
||
try {
|
||
JsonNode root = MAPPER.readTree(json);
|
||
JsonNode payload = root.get("payload");
|
||
if (payload == null) return res;
|
||
JsonNode arr = payload.get("sessions");
|
||
if (arr == null || !arr.isArray()) return res;
|
||
|
||
for (JsonNode s : arr) {
|
||
JsonNode id = s.get("sessionId");
|
||
if (id != null && !id.isNull()) res.add(id.asText());
|
||
}
|
||
} catch (Exception ignored) {}
|
||
return res;
|
||
}
|
||
|
||
public static String errorCode(String json) {
|
||
try {
|
||
JsonNode root = MAPPER.readTree(json);
|
||
|
||
// поддержка старого формата (верхний уровень)
|
||
if (root.has("errorCode")) return root.get("errorCode").asText();
|
||
// поддержка нового формата (верхний уровень)
|
||
if (root.has("code")) return root.get("code").asText();
|
||
|
||
JsonNode payload = root.get("payload");
|
||
if (payload != null) {
|
||
// поддержка старого формата (внутри payload)
|
||
if (payload.has("errorCode")) return payload.get("errorCode").asText();
|
||
// поддержка нового формата (внутри payload)
|
||
if (payload.has("code")) return payload.get("code").asText();
|
||
}
|
||
} catch (Exception ignored) {}
|
||
|
||
return null;
|
||
}
|
||
|
||
// ---------------- GetUser helpers ----------------
|
||
|
||
public static Boolean exists(String json) {
|
||
try {
|
||
JsonNode root = MAPPER.readTree(json);
|
||
JsonNode payload = root.get("payload");
|
||
if (payload != null && payload.has("exists")) return payload.get("exists").asBoolean();
|
||
return null;
|
||
} catch (Exception e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
public static String userLogin(String json) {
|
||
return getPayloadText(json, "login");
|
||
}
|
||
|
||
public static String userBlockchainName(String json) {
|
||
return getPayloadText(json, "blockchainName");
|
||
}
|
||
|
||
public static String userSolanaKey(String json) {
|
||
return getPayloadText(json, "solanaKey");
|
||
}
|
||
|
||
public static String userBlockchainKey(String json) {
|
||
return getPayloadText(json, "blockchainKey");
|
||
}
|
||
|
||
public static String userDeviceKey(String json) {
|
||
return getPayloadText(json, "deviceKey");
|
||
}
|
||
|
||
// ---------------- SearchUsers helpers ----------------
|
||
|
||
public static List<String> searchLogins(String json) {
|
||
List<String> res = new ArrayList<>();
|
||
try {
|
||
JsonNode root = MAPPER.readTree(json);
|
||
JsonNode payload = root.get("payload");
|
||
if (payload == null) return res;
|
||
|
||
JsonNode arr = payload.get("logins");
|
||
if (arr == null || !arr.isArray()) return res;
|
||
|
||
for (JsonNode x : arr) {
|
||
if (x != null && !x.isNull()) res.add(x.asText());
|
||
}
|
||
} catch (Exception ignored) {}
|
||
return res;
|
||
}
|
||
|
||
// ---------------- Friends helpers ----------------
|
||
|
||
/** payload.login (канонический) */
|
||
public static String friendsLogin(String json) {
|
||
return getPayloadText(json, "login");
|
||
}
|
||
|
||
public static List<String> friendsOut(String json) {
|
||
return getPayloadStringArray(json, "out_friends");
|
||
}
|
||
|
||
public static List<String> friendsIn(String json) {
|
||
return getPayloadStringArray(json, "in_friends");
|
||
}
|
||
|
||
public static List<String> friendsMutual(String json) {
|
||
return getPayloadStringArray(json, "mutual_friends");
|
||
}
|
||
|
||
private static List<String> getPayloadStringArray(String json, String field) {
|
||
List<String> res = new ArrayList<>();
|
||
try {
|
||
JsonNode root = MAPPER.readTree(json);
|
||
JsonNode payload = root.get("payload");
|
||
if (payload == null) return res;
|
||
|
||
JsonNode arr = payload.get(field);
|
||
if (arr == null || !arr.isArray()) return res;
|
||
|
||
for (JsonNode x : arr) {
|
||
if (x != null && !x.isNull()) res.add(x.asText());
|
||
}
|
||
} catch (Exception ignored) {}
|
||
return res;
|
||
}
|
||
|
||
private static String getPayloadText(String json, String field) {
|
||
try {
|
||
JsonNode root = MAPPER.readTree(json);
|
||
JsonNode payload = root.get("payload");
|
||
if (payload != null && payload.has(field) && !payload.get(field).isNull()) {
|
||
return payload.get(field).asText();
|
||
}
|
||
return null;
|
||
} catch (Exception e) {
|
||
return null;
|
||
}
|
||
}
|
||
}
|
||
package test.it.utils.log;
|
||
|
||
import test.it.utils.TestConfig;
|
||
|
||
/**
|
||
* TestLog — единое место для:
|
||
* - ANSI цветов
|
||
* - стандартных сообщений (title/step/send/recv)
|
||
* - PASS/FAIL строк и окраски
|
||
*
|
||
* Режим:
|
||
* - it.debug=false: печатаем минимум (без JSON)
|
||
* - it.debug=true: печатаем JSON отправка/ответ + заголовки шагов
|
||
*/
|
||
public final class TestLog {
|
||
private TestLog() {}
|
||
|
||
public static final boolean DEBUG = TestConfig.DEBUG();
|
||
|
||
// ANSI COLORS (ТОЛЬКО ТУТ)
|
||
public static final String R = "\u001B[0m";
|
||
public static final String G = "\u001B[32m";
|
||
public static final String Y = "\u001B[33m";
|
||
public static final String RED = "\u001B[31m";
|
||
public static final String C = "\u001B[36m";
|
||
|
||
public static String green(String s) { return G + s + R; }
|
||
public static String red(String s) { return RED + s + R; }
|
||
public static String cyan(String s) { return C + s + R; }
|
||
|
||
/** Инфо (печатается только при DEBUG=true). */
|
||
public static void info(String s) {
|
||
if (DEBUG) System.out.println(s);
|
||
}
|
||
|
||
public static void line() {
|
||
if (!DEBUG) return;
|
||
System.out.println(C + "------------------------------------------------------------" + R);
|
||
}
|
||
|
||
public static void title(String s) {
|
||
if (!DEBUG) return;
|
||
System.out.println(C + "\n============================================================" + R);
|
||
System.out.println(C + s + R);
|
||
System.out.println(C + "============================================================\n" + R);
|
||
}
|
||
|
||
public static void titleBlock(String multiLineText) {
|
||
if (!DEBUG) return;
|
||
System.out.println(C + "\n============================================================" + R);
|
||
System.out.println(C + multiLineText + R);
|
||
System.out.println(C + "============================================================\n" + R);
|
||
}
|
||
|
||
public static void stepTitle(String s) {
|
||
if (!DEBUG) return;
|
||
System.out.println(C + "\n-------------------- " + s + " --------------------" + R);
|
||
}
|
||
|
||
/** OK (печатаем ВСЕГДА, чтобы было видно зелёное прохождение шагов). */
|
||
public static void ok(String s) {
|
||
System.out.println(G + "✅ " + s + R);
|
||
}
|
||
|
||
/** WARN (только DEBUG). */
|
||
public static void warn(String s) {
|
||
if (!DEBUG) return;
|
||
System.out.println(Y + "⚠️ " + s + R);
|
||
}
|
||
|
||
/** FAIL (печатаем ВСЕГДА). */
|
||
public static void boom(String s) {
|
||
System.out.println(RED + "****************************************************************" + R);
|
||
System.out.println(RED + "❌ " + s + R);
|
||
System.out.println(RED + "****************************************************************" + R);
|
||
}
|
||
|
||
public static void send(String op, String json) {
|
||
if (!DEBUG) return;
|
||
System.out.println("📤 [" + op + "] Request JSON:");
|
||
System.out.println(json);
|
||
line();
|
||
}
|
||
|
||
public static void recv(String op, String json) {
|
||
if (!DEBUG) return;
|
||
System.out.println("📥 [" + op + "] Response JSON:");
|
||
System.out.println(json);
|
||
line();
|
||
}
|
||
}
|
||
package test.it.utils.log;
|
||
|
||
import java.util.ArrayList;
|
||
import java.util.List;
|
||
|
||
/**
|
||
* TestResult — накопитель результатов внутри одного теста:
|
||
* - ok(...) печатает зелёным
|
||
* - fail(...) печатает красным и добавляет в итоговую строку
|
||
* - summaryLine() возвращает одну строку: PASS/FAIL + детали
|
||
*/
|
||
public final class TestResult {
|
||
|
||
private final String testName;
|
||
private final List<String> errors = new ArrayList<>();
|
||
|
||
public TestResult(String testName) {
|
||
this.testName = testName;
|
||
}
|
||
|
||
public void ok(String msg) {
|
||
TestLog.ok(msg);
|
||
}
|
||
|
||
public void fail(String msg) {
|
||
errors.add(msg);
|
||
TestLog.boom(msg);
|
||
}
|
||
|
||
public boolean isOk() {
|
||
return errors.isEmpty();
|
||
}
|
||
|
||
public String summaryLine() {
|
||
if (errors.isEmpty()) {
|
||
return TestLog.green("PASS: " + testName + " — OK");
|
||
}
|
||
StringBuilder sb = new StringBuilder();
|
||
sb.append(TestLog.red("FAIL: ")).append(testName).append(" — ").append(errors.size()).append(" ошибок: ");
|
||
for (int i = 0; i < errors.size(); i++) {
|
||
if (i > 0) sb.append(" | ");
|
||
sb.append(errors.get(i));
|
||
}
|
||
return sb.toString();
|
||
}
|
||
}
|
||
package test.it.utils;
|
||
|
||
import utils.crypto.Ed25519Util;
|
||
|
||
import java.util.Base64;
|
||
import java.util.Map;
|
||
import java.util.concurrent.ConcurrentHashMap;
|
||
|
||
/**
|
||
* TestConfig — конфиг IT тестов:
|
||
* - 3 пользователя (TestUser1/2/3)
|
||
* - ключи по login через map (device/solana/blockchain/session)
|
||
* - blockchainName = login + "-" + "001"
|
||
*
|
||
* Важно:
|
||
* - privateKey = Ed25519Util.generatePrivateKeyFromString(seed) (sha256(seed) => 32 bytes)
|
||
* - publicKey = Ed25519Util.derivePublicKey(privateKey)
|
||
* - device/solana/blockchain ключи пока одинаковые (seed = login)
|
||
* - session ключ отдельный (seed = "session:" + login) — чтобы SessionLogin был честнее.
|
||
*/
|
||
public final class TestConfig {
|
||
|
||
private TestConfig() {}
|
||
|
||
public static final String WS_URI_LOCAL = "ws://localhost:7070/ws";
|
||
public static final String WS_URI_Server = "wss://shineup.me/ws";
|
||
|
||
// по умолчанию LOCAL, но можно переопределить: -Dit.wsUri=...
|
||
public static final String WS_URI = System.getProperty("it.wsUri", WS_URI_LOCAL);
|
||
|
||
public static final long TEST_BCH_LIMIT = 50_000_000L;
|
||
public static final String TEST_CLIENT_INFO = "it-tests";
|
||
|
||
public static boolean DEBUG() {
|
||
return Boolean.parseBoolean(System.getProperty("it.debug", "true"));
|
||
}
|
||
|
||
// 3 users
|
||
public static final String DEFAULT_LOGIN1 = "TestUser1";
|
||
public static final String DEFAULT_LOGIN2 = "TestUser2";
|
||
public static final String DEFAULT_LOGIN3 = "TestUser3";
|
||
public static final String DEFAULT_BCH_SUFFIX_3 = "001";
|
||
|
||
public static String LOGIN() { return System.getProperty("it.login1", DEFAULT_LOGIN1); }
|
||
public static String LOGIN2() { return System.getProperty("it.login2", DEFAULT_LOGIN2); }
|
||
public static String LOGIN3() { return System.getProperty("it.login3", DEFAULT_LOGIN3); }
|
||
|
||
public static String BCH_SUFFIX_3() {
|
||
return System.getProperty("it.bchSuffix", DEFAULT_BCH_SUFFIX_3);
|
||
}
|
||
|
||
public static String getBlockchainName(String login) {
|
||
if (login == null) throw new IllegalArgumentException("login is null");
|
||
return login + "-" + BCH_SUFFIX_3();
|
||
}
|
||
|
||
// ============ key maps ============
|
||
private static final Map<String, byte[]> devicePriv = new ConcurrentHashMap<>();
|
||
private static final Map<String, byte[]> devicePub = new ConcurrentHashMap<>();
|
||
|
||
private static final Map<String, byte[]> solanaPriv = new ConcurrentHashMap<>();
|
||
private static final Map<String, byte[]> solanaPub = new ConcurrentHashMap<>();
|
||
|
||
private static final Map<String, byte[]> bchPriv = new ConcurrentHashMap<>();
|
||
private static final Map<String, byte[]> bchPub = new ConcurrentHashMap<>();
|
||
|
||
// NEW: session keys (для SessionLogin v2)
|
||
private static final Map<String, byte[]> sessionPriv = new ConcurrentHashMap<>();
|
||
private static final Map<String, byte[]> sessionPub = new ConcurrentHashMap<>();
|
||
|
||
static {
|
||
initUserKeys(LOGIN());
|
||
initUserKeys(LOGIN2());
|
||
initUserKeys(LOGIN3());
|
||
}
|
||
|
||
private static void initUserKeys(String login) {
|
||
// seed = login
|
||
byte[] priv = Ed25519Util.generatePrivateKeyFromString(login); // sha256(login) => 32 bytes
|
||
byte[] pub = Ed25519Util.derivePublicKey(priv);
|
||
|
||
// пока одинаковые
|
||
devicePriv.put(login, priv);
|
||
devicePub.put(login, pub);
|
||
|
||
solanaPriv.put(login, priv);
|
||
solanaPub.put(login, pub);
|
||
|
||
bchPriv.put(login, priv);
|
||
bchPub.put(login, pub);
|
||
|
||
// session seed = "session:" + login (отдельно!)
|
||
byte[] sPriv = Ed25519Util.generatePrivateKeyFromString("session:" + login);
|
||
byte[] sPub = Ed25519Util.derivePublicKey(sPriv);
|
||
|
||
sessionPriv.put(login, sPriv);
|
||
sessionPub.put(login, sPub);
|
||
}
|
||
|
||
// ============ requested getters (with your names) ============
|
||
|
||
public static byte[] getDevicePrivatKey(String login) { return cloneOrThrow(devicePriv.get(login), "devicePriv", login); }
|
||
public static byte[] getDevicePublicKey(String login) { return cloneOrThrow(devicePub.get(login), "devicePub", login); }
|
||
|
||
public static byte[] getSolanaPrivatKey(String login) { return cloneOrThrow(solanaPriv.get(login), "solanaPriv", login); }
|
||
public static byte[] getSolanaPublicKey(String login) { return cloneOrThrow(solanaPub.get(login), "solanaPub", login); }
|
||
|
||
public static byte[] getBlockchainPrivatKey(String login) { return cloneOrThrow(bchPriv.get(login), "bchPriv", login); }
|
||
public static byte[] getBlockchainPublicKey(String login) { return cloneOrThrow(bchPub.get(login), "bchPub", login); }
|
||
|
||
// NEW: session getters
|
||
public static byte[] getSessionPrivatKey(String login) { return cloneOrThrow(sessionPriv.get(login), "sessionPriv", login); }
|
||
public static byte[] getSessionPublicKey(String login) { return cloneOrThrow(sessionPub.get(login), "sessionPub", login); }
|
||
|
||
// ============ base64 helpers ============
|
||
public static String devicePublicKeyB64(String login) { return Base64.getEncoder().encodeToString(getDevicePublicKey(login)); }
|
||
public static String solanaPublicKeyB64(String login) { return Base64.getEncoder().encodeToString(getSolanaPublicKey(login)); }
|
||
public static String blockchainPublicKeyB64(String login) { return Base64.getEncoder().encodeToString(getBlockchainPublicKey(login)); }
|
||
|
||
// NEW: session pub b64 helper
|
||
public static String sessionPublicKeyB64(String login) { return Base64.getEncoder().encodeToString(getSessionPublicKey(login)); }
|
||
|
||
// ============ backward-compatible helpers for "user1" ============
|
||
public static String BCH_NAME() { return getBlockchainName(LOGIN()); }
|
||
public static String BCH_NAME2() { return getBlockchainName(LOGIN2()); }
|
||
public static String BCH_NAME3() { return getBlockchainName(LOGIN3()); }
|
||
|
||
/** solanaKey для AddUser: публичный ключ Solana-пользователя */
|
||
public static String SOLANA_PUBKEY_B64() { return solanaPublicKeyB64(LOGIN()); }
|
||
public static String SOLANA2_PUBKEY_B64() { return solanaPublicKeyB64(LOGIN2()); }
|
||
public static String SOLANA3_PUBKEY_B64() { return solanaPublicKeyB64(LOGIN3()); }
|
||
|
||
/** blockchainKey для AddUser: публичный ключ блокчейна */
|
||
public static String BLOCKCHAIN_PUBKEY_B64() { return blockchainPublicKeyB64(LOGIN()); }
|
||
public static String BLOCKCHAIN2_PUBKEY_B64() { return blockchainPublicKeyB64(LOGIN2()); }
|
||
public static String BLOCKCHAIN3_PUBKEY_B64() { return blockchainPublicKeyB64(LOGIN3()); }
|
||
|
||
public static String DEVICE_PUBKEY_B64() { return devicePublicKeyB64(LOGIN()); }
|
||
public static String DEVICE2_PUBKEY_B64() { return devicePublicKeyB64(LOGIN2()); }
|
||
public static String DEVICE3_PUBKEY_B64() { return devicePublicKeyB64(LOGIN3()); }
|
||
|
||
// NEW: session pub b64 compat
|
||
public static String SESSION_PUBKEY_B64() { return sessionPublicKeyB64(LOGIN()); }
|
||
public static String SESSION2_PUBKEY_B64() { return sessionPublicKeyB64(LOGIN2()); }
|
||
public static String SESSION3_PUBKEY_B64() { return sessionPublicKeyB64(LOGIN3()); }
|
||
|
||
// ============ misc ============
|
||
public static String fakeStoragePwd() {
|
||
return "pwd-" + System.nanoTime();
|
||
}
|
||
|
||
private static byte[] cloneOrThrow(byte[] v, String mapName, String login) {
|
||
if (login == null) throw new IllegalArgumentException("login is null");
|
||
if (v == null) throw new IllegalStateException("No key in " + mapName + " for login=" + login);
|
||
return v.clone();
|
||
}
|
||
}
|
||
package test.it.utils;
|
||
|
||
import java.util.concurrent.atomic.AtomicLong;
|
||
|
||
/** Генератор уникальных requestId для IT тестов (в пределах одной JVM). */
|
||
public final class TestIds {
|
||
private static final AtomicLong SEQ = new AtomicLong(0);
|
||
|
||
private TestIds() {}
|
||
|
||
public static String next(String prefix) {
|
||
long n = SEQ.incrementAndGet();
|
||
return "it-" + (prefix == null ? "req" : prefix) + "-" + n;
|
||
}
|
||
}
|
||
package test.it.utils.ws;
|
||
|
||
import com.fasterxml.jackson.databind.JsonNode;
|
||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||
import test.it.utils.TestConfig;
|
||
import test.it.utils.log.TestLog;
|
||
|
||
import java.time.Duration;
|
||
|
||
/**
|
||
* WsSession — одно WS соединение на много запросов.
|
||
*
|
||
* Использование в тесте:
|
||
* try (WsSession ws = WsSession.open()) {
|
||
* String resp = ws.call("AuthChallenge", JsonBuilders.authChallenge(login), t);
|
||
* }
|
||
*/
|
||
public final class WsSession implements AutoCloseable {
|
||
|
||
private static final ObjectMapper M = new ObjectMapper();
|
||
|
||
private final WsTestClient client;
|
||
|
||
private WsSession(WsTestClient client) {
|
||
this.client = client;
|
||
}
|
||
|
||
public static WsSession open() {
|
||
return new WsSession(new WsTestClient(TestConfig.WS_URI));
|
||
}
|
||
|
||
/** Отправить JSON (в котором уже есть requestId) и получить JSON ответ строкой. */
|
||
public String call(String op, String requestJson, Duration timeout) {
|
||
String requestId = extractRequestId(requestJson);
|
||
if (requestId == null || requestId.isBlank()) throw new IllegalArgumentException("requestJson must contain requestId: " + requestJson);
|
||
|
||
if (TestConfig.DEBUG()) TestLog.send(op, requestJson);
|
||
|
||
String resp = client.request(requestId, requestJson, timeout);
|
||
|
||
if (TestConfig.DEBUG()) TestLog.recv(op, resp);
|
||
|
||
return resp;
|
||
}
|
||
|
||
private static String extractRequestId(String json) {
|
||
try {
|
||
JsonNode root = M.readTree(json);
|
||
JsonNode id = root.get("requestId");
|
||
return (id == null || id.isNull()) ? null : id.asText();
|
||
} catch (Exception e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
@Override
|
||
public void close() {
|
||
client.close();
|
||
}
|
||
}
|
||
package test.it.utils.ws;
|
||
|
||
import com.fasterxml.jackson.databind.JsonNode;
|
||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||
|
||
import java.net.URI;
|
||
import java.net.http.HttpClient;
|
||
import java.net.http.WebSocket;
|
||
import java.time.Duration;
|
||
import java.util.Map;
|
||
import java.util.concurrent.*;
|
||
|
||
public final class WsTestClient implements AutoCloseable {
|
||
|
||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||
|
||
private final WebSocket ws;
|
||
private final Map<String, CompletableFuture<String>> pending = new ConcurrentHashMap<>();
|
||
|
||
public WsTestClient(String wsUri) {
|
||
HttpClient client = HttpClient.newHttpClient();
|
||
this.ws = client.newWebSocketBuilder()
|
||
.connectTimeout(Duration.ofSeconds(5))
|
||
.buildAsync(URI.create(wsUri), new WebSocket.Listener() {
|
||
@Override
|
||
public CompletionStage<?> onText(WebSocket webSocket, CharSequence data, boolean last) {
|
||
String msg = data.toString();
|
||
String requestId = extractRequestId(msg);
|
||
if (requestId != null) {
|
||
CompletableFuture<String> f = pending.remove(requestId);
|
||
if (f != null) f.complete(msg);
|
||
}
|
||
webSocket.request(1);
|
||
return CompletableFuture.completedFuture(null);
|
||
}
|
||
|
||
@Override
|
||
public void onError(WebSocket webSocket, Throwable error) {
|
||
// Завалим все ожидания, чтобы тест корректно упал
|
||
pending.forEach((k, f) -> f.completeExceptionally(error));
|
||
pending.clear();
|
||
}
|
||
}).join();
|
||
|
||
this.ws.request(1);
|
||
}
|
||
|
||
public String request(String requestId, String json, Duration timeout) {
|
||
CompletableFuture<String> fut = new CompletableFuture<>();
|
||
pending.put(requestId, fut);
|
||
ws.sendText(json, true);
|
||
try {
|
||
return fut.get(timeout.toMillis(), TimeUnit.MILLISECONDS);
|
||
} catch (Exception e) {
|
||
pending.remove(requestId);
|
||
throw new RuntimeException("Timeout/Fail waiting response requestId=" + requestId, e);
|
||
}
|
||
}
|
||
|
||
private static String extractRequestId(String json) {
|
||
try {
|
||
JsonNode root = MAPPER.readTree(json);
|
||
JsonNode id = root.get("requestId");
|
||
return id != null && !id.isNull() ? id.asText() : null;
|
||
} catch (Exception ignored) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
@Override
|
||
public void close() {
|
||
try {
|
||
ws.sendClose(WebSocket.NORMAL_CLOSURE, "bye").join();
|
||
} catch (Exception ignored) {}
|
||
}
|
||
}
|