Preserve unsigned node IDs in Android invite previews

Encode JNI node IDs as decimal strings and decode both uint64 fields without floating point or signed parsing. Compare all invitation fields and binary contents. Cover boundary IDs, malformed JSON types, and model equality with nine JVM tests. Native build, invite codec/build tests, and both debug APK ABIs passed.
This commit is contained in:
evgeny committed 2026-10-27 14:22:32 +01:00
1 parent 254029be63
commit 3e96e429eb
4 files changed
+142 -23

No files matched your search

@@ -0,0 +1,78 @@
package com.utun.chat.data
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Test
/** Значения JNI проверяются как строки uint64; Android Number/Double не сохраняет весь диапазон ID. */
class InviteLinkTest {
private fun reality() = InviteReality(true, ByteArray(8) { 1 }, ByteArray(32) { 2 }, byteArrayOf(1, 2, 3), "example.test")
private fun invite() = InviteData(71, 13, ByteArray(32) { 3 }, 19,
listOf(InviteAddr(4, byteArrayOf(127, 0, 0, 1), 9999, 7, INVITE_PROTO_TCP or INVITE_PROTO_REALITY)),
"password", reality(), "utun://original")
private fun preview(node: String, key: String = "13") = """
{"channel":71,"key":"$key","node":"$node","pubkey":"${"03".repeat(32)}","password":"70617373776f7264",
"reality":{"short":"${"01".repeat(8)}","pubkey":"${"02".repeat(32)}","version":"010203","name":"6578616d706c652e74657374"},
"addresses":[{"family":4,"socket":7,"proto":6,"port":9999,"ip":"7f000001"}]}
""".trimIndent()
@Test fun nativePreviewPreservesEveryNodeBit() {
for (node in listOf(1uL, Long.MAX_VALUE.toULong(), 1uL shl 63, 0xfedcba9876543210uL, ULong.MAX_VALUE)) {
val actual = InviteLink.fromNativePreview(preview(node.toString()), "utun://original")
assertEquals(node.toLong(), actual.nodeId)
assertEquals(node, actual.nodeId.toULong())
}
}
@Test fun joinKeyAndAllPreviewFieldsSurviveMapping() {
assertEquals(invite().copy(joinKey = -1), InviteLink.fromNativePreview(preview("19", ULong.MAX_VALUE.toString()), "utun://original"))
}
@Test fun floatingPointNodeIsRejected() {
// Именно такой тип может получиться после разбора JSON-числа > Long.MAX_VALUE в Android.
val value = JSONObject(preview("19")).put("node", 18446744073709551615.0)
assertThrows(Exception::class.java) { InviteLink.fromNativePreview(value.toString(), "utun://original") }
}
@Test fun uint64FieldsRequireStringsRegardlessOfTheirValue() {
for (field in listOf("key", "node")) for (value in listOf(19L, 19.0, true, JSONObject.NULL)) {
val json = JSONObject(preview("19")).put(field, value)
assertThrows(Exception::class.java) { InviteLink.fromNativePreview(json.toString(), "utun://original") }
}
}
@Test fun signedChannelRangeKeepsExactMaximumValue() {
val json = JSONObject(preview("19")).put("channel", Long.MAX_VALUE)
assertEquals(Long.MAX_VALUE, InviteLink.fromNativePreview(json.toString(), "utun://original").channelId)
}
@Test fun unsignedNodeAndKeyRejectOutOfRangeStrings() {
for (bad in listOf("-1", "18446744073709551616", "1.5", "not-a-number")) {
assertThrows(Exception::class.java) { InviteLink.fromNativePreview(preview(bad), "utun://original") }
assertThrows(Exception::class.java) { InviteLink.fromNativePreview(preview("19", bad), "utun://original") }
}
}
@Test fun independentSnapshotsHaveStructuralEquality() {
val first = invite(); val second = invite()
assertEquals(first, second); assertEquals(first.hashCode(), second.hashCode())
assertEquals(first.reality, second.reality); assertEquals(first.reality.hashCode(), second.reality.hashCode())
assertEquals(1, hashSetOf(first, second).size)
}
@Test fun everyInvitationFieldParticipatesInEquality() {
val first = invite()
val changed = listOf(first.copy(channelId = 72), first.copy(joinKey = 14), first.copy(pubkey = ByteArray(32) { 4 }),
first.copy(nodeId = 20), first.copy(addrs = emptyList()), first.copy(password = "different"),
first.copy(reality = null), first.copy(link = "utun://different"))
changed.forEach { assertNotEquals(first, it) }
assertEquals(changed.size + 1, (changed + first).toSet().size)
}
@Test fun everyRealityFieldParticipatesInEquality() {
val first = reality()
listOf(first.copy(has = false), first.copy(shortId = ByteArray(8)), first.copy(serverPubkey = ByteArray(32)),
first.copy(version = byteArrayOf(4, 5, 6)), first.copy(serverName = "other.test")).forEach { assertNotEquals(first, it) }
}
}