From 15dbb6fe5187e629bd86ef56d26a0ec389abf81c Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 16:32:36 +0200 Subject: [PATCH] android: test avatar event framing, limits and member updates --- tools/chatgui-android/app/build.gradle.kts | 1 + .../com/utun/chat/data/AvatarProtocolTest.kt | 80 +++++++++++++++++++ .../com/utun/chat/data/MemberAvatarTest.kt | 39 +++++++++ 3 files changed, 120 insertions(+) create mode 100644 tools/chatgui-android/app/src/test/java/com/utun/chat/data/AvatarProtocolTest.kt create mode 100644 tools/chatgui-android/app/src/test/java/com/utun/chat/data/MemberAvatarTest.kt diff --git a/tools/chatgui-android/app/build.gradle.kts b/tools/chatgui-android/app/build.gradle.kts index fdc24727..bbad6c85 100644 --- a/tools/chatgui-android/app/build.gradle.kts +++ b/tools/chatgui-android/app/build.gradle.kts @@ -78,6 +78,7 @@ android { } dependencies { + testImplementation("junit:junit:4.13.2") implementation(platform("androidx.compose:compose-bom:2024.02.00")) implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-tooling-preview") diff --git a/tools/chatgui-android/app/src/test/java/com/utun/chat/data/AvatarProtocolTest.kt b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/AvatarProtocolTest.kt new file mode 100644 index 00000000..1085cf61 --- /dev/null +++ b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/AvatarProtocolTest.kt @@ -0,0 +1,80 @@ +package com.utun.chat.data + +import org.junit.Assert.* +import org.junit.Test +import java.nio.ByteBuffer +import java.nio.ByteOrder + +class AvatarProtocolTest { + private val uid = 0xfedcba9876543210uL.toLong() + private fun buffer(size: Int) = ByteBuffer.allocate(size).order(ByteOrder.LITTLE_ENDIAN) + private fun group(size: Int = 0): ByteArray = buffer(60 + size).apply { + putLong(71); putLong(uid); putLong(123) + put(ByteArray(32) { 0xa5.toByte() }); put(1); put(0); putShort(size.toShort()) + put(ByteArray(size) { 0x7f }) + }.array() + private fun response(): ByteArray = buffer(141).apply { + putLong(uid); putLong(23) + put(4); put(2); put(2); putShort(0) + putLong(71); putLong(uid); putLong(0); putLong(512) + put(16 + 117, 1) + }.array() + + @Test fun unsignedNodeAndContentAddress() { + assertEquals(uid, AvatarProtocol.node("fedcba9876543210")) + assertEquals("fedcba9876543210", AvatarProtocol.hex(uid)) + assertTrue(AvatarProtocol.validFile("a".repeat(64) + ".png")) + listOf("A".repeat(64) + ".png", "../" + "a".repeat(64) + ".png", "a.png", "a".repeat(64) + ".jpg") + .forEach { assertFalse(it, AvatarProtocol.validFile(it)) } + assertEquals(0L, AvatarProtocol.node("invalid")) + } + @Test fun groupLimitAndSignedDeletion() { + val record = AvatarProtocol.group(group(16384)) + assertEquals(71L, record.id); assertEquals(uid, record.group); assertEquals(123L, record.version) + assertEquals("a5".repeat(32), record.hash); assertTrue(record.canEdit); assertFalse(record.failed) + assertEquals(16384, record.image.size) + assertEquals(0, AvatarProtocol.group(group()).image.size) + } + @Test fun rejectTruncatedOversizedAndMalformedGroup() { + assertThrows(IllegalArgumentException::class.java) { AvatarProtocol.group(group(1).copyOf(60)) } + assertThrows(IllegalArgumentException::class.java) { AvatarProtocol.group(group(16385)) } + assertThrows(IllegalArgumentException::class.java) { AvatarProtocol.group(group().also { it[56] = 2 }) } + assertThrows(IllegalArgumentException::class.java) { AvatarProtocol.group(group().also { it[57] = -1 }) } + } + @Test fun statHasSizeAndTypeWithoutAContentHash() { + // STAT общего сервера возвращает метаданные; хеш присутствует только после COMMIT/stream-open. + val reply = AvatarProtocol.response(response()) + assertEquals(uid, reply.group); assertEquals(23L, reply.server); assertEquals(uid, reply.owner) + assertEquals(71L, reply.id); assertEquals(2, reply.command); assertEquals(0, reply.status) + assertEquals(512L, reply.size); assertEquals(1, reply.fileType); assertEquals("0".repeat(64), reply.hash) + } + @Test fun rejectInvalidFileFraming() { + assertThrows(IllegalArgumentException::class.java) { AvatarProtocol.response(response().copyOf(140)) } + assertThrows(IllegalArgumentException::class.java) { AvatarProtocol.response(response().also { it[16] = 3 }) } + assertThrows(IllegalArgumentException::class.java) { AvatarProtocol.response(response().also { it[17] = 1 }) } + assertThrows(IllegalArgumentException::class.java) { AvatarProtocol.response(response().also { it[135] = 1 }) } + val path = response().copyOf(144).also { it[135] = 3; "abc".toByteArray().copyInto(it, 141) } + assertEquals(71L, AvatarProtocol.response(path).id) + } + @Test fun progressMustFinishBeforeResolvingARequest() { + val progress = buffer(51).apply { + putLong(71); putLong(uid); putLong(23); putLong(uid) + putLong(0); putLong(512); putShort(0); put(1) + }.array() + assertNull(AvatarProtocol.transfer(progress)) + progress[50] = 2 + assertEquals(71L, AvatarProtocol.transfer(progress)?.id) + progress[50] = 3 + assertThrows(IllegalArgumentException::class.java) { AvatarProtocol.transfer(progress) } + progress[50] = 2 + ByteBuffer.wrap(progress).order(ByteOrder.LITTLE_ENDIAN).putLong(32, 513) + assertThrows(IllegalArgumentException::class.java) { AvatarProtocol.transfer(progress) } + } + @Test fun failedPublicationRemainsAnError() { + val result = buffer(17).apply { putLong(71); putLong(uid); put(1) }.array() + assertEquals(1, AvatarProtocol.published(result).status) + assertEquals(uid, AvatarProtocol.published(result).group) + assertThrows(IllegalArgumentException::class.java) { AvatarProtocol.published(result.copyOf(16)) } + assertThrows(IllegalArgumentException::class.java) { AvatarProtocol.published(result.also { it[16] = 2 }) } + } +} diff --git a/tools/chatgui-android/app/src/test/java/com/utun/chat/data/MemberAvatarTest.kt b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/MemberAvatarTest.kt new file mode 100644 index 00000000..8bc9b46c --- /dev/null +++ b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/MemberAvatarTest.kt @@ -0,0 +1,39 @@ +package com.utun.chat.data + +import org.junit.Assert.* +import org.junit.Test +import java.nio.ByteBuffer +import java.nio.ByteOrder + +class MemberAvatarTest { + @Test fun sharedSnapshotKeepsAvatarAndSignedStorageRole() { + val bytes = ByteArray(157) + val b = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN) + b.putLong(0, 123); bytes[8] = 1; bytes[10] = 2 + "Alice".toByteArray().copyInto(bytes, 15) + val server = 0xfedcba9876543210uL.toLong() + b.putLong(79, server) + val file = "b".repeat(64) + ".png" + file.toByteArray().copyInto(bytes, 87); bytes[156] = 1 + val previous = ChatMember(123, "Old", isVerified = true, nodeType = 3, directUp = true, bgpNodes = 7) + val member = requireNotNull(parseMemberWire(bytes)).toChatMember(previous) + assertEquals("Alice", member.name); assertTrue(member.online); assertTrue(member.isAdmin) + assertEquals(server, member.avatarServer); assertEquals(file, member.avatarFile); assertTrue(member.storageRole) + assertTrue(member.isVerified); assertEquals(3, member.nodeType); assertTrue(member.directUp); assertEquals(7, member.bgpNodes) + // Сеть передаёт storage priority отдельно от подписанной роли хранителя. + assertFalse(member.isStorage) + } + @Test fun clearingReferenceDoesNotKeepAnOldAvatar() { + val previous = ChatMember(123, "Alice", avatarServer = 456, avatarFile = "b".repeat(64) + ".png", storageRole = true) + val updated = MemberWire(123, 1, true, 0, 0, 0, false, "Alice").toChatMember(previous) + assertEquals(0L, updated.avatarServer); assertEquals("", updated.avatarFile); assertFalse(updated.storageRole) + assertTrue(updated.isSelf) + } + @Test fun recordsAtAnOffsetRequireAll157Bytes() { + val bytes = ByteArray(160) + ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).putLong(3, 987) + assertEquals(987L, parseMemberWire(bytes, 3)?.nodeId) + assertNull(parseMemberWire(bytes, 4)); assertNull(parseMemberWire(bytes, -1)) + assertNull(parseMemberWire(bytes.copyOf(156))) + } +}