Files
MicrOBU/app/src/test/java/com/hawhamburg/micr0bu/PseudonymTest.kt
T

97 lines
3.7 KiB
Kotlin
Raw Normal View History

package com.hawhamburg.micr0bu
import com.hawhamburg.micr0bu.domain.cam.OwnStationIds
import com.hawhamburg.micr0bu.domain.cam.Pseudonym
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.random.Random
/**
* Pins what a transmit pseudonym is allowed to look like, and when it rotates.
*
* The address rules matter on air, not just in the app: the ESP32 writes this MAC straight into
* the 802.11 source address. A group (multicast) source address is invalid, and a random address
* without the locally-administered bit claims to belong to a real hardware vendor.
*/
class PseudonymTest {
@Test
fun `rotates every ten minutes`() {
assertEquals(10 * 60_000L, Pseudonym.ROTATION_INTERVAL_MS)
}
@Test
fun `expires exactly at the rotation interval, not a millisecond before`() {
val p = Pseudonym(stationId = 42L, mac = mac(0x02), createdAtMs = 1_000L)
assertFalse(p.isExpired(1_000L + Pseudonym.ROTATION_INTERVAL_MS - 1))
assertTrue(p.isExpired(1_000L + Pseudonym.ROTATION_INTERVAL_MS))
}
@Test
fun `a clock that moved back past the creation time forces a rotation`() {
// Otherwise a creation time now lying in the future would pin one identity until the
// clock caught up, which after a large correction could be hours.
val p = Pseudonym(stationId = 42L, mac = mac(0x02), createdAtMs = 1_000L)
assertTrue(p.isExpired(999L))
}
@Test
fun `generated addresses are locally administered unicast, whatever the random bytes`() {
repeat(500) { seed ->
val first = Pseudonym.generate(0L, Random(seed)).mac[0].toInt()
assertEquals("seed $seed: bit 1 set, bit 0 clear", 0x02, first and 0x03)
}
}
@Test
fun `generated station ids stay in range`() {
repeat(500) { seed ->
val id = Pseudonym.generate(0L, Random(seed)).stationId
assertTrue("seed $seed: $id", id in 1L until 0xFFFF_FFFEL)
}
}
@Test
fun `never generates the bench pinger's identity`() {
// Scripted so the exclusion loops actually run: the first draw of each is the bench
// value, which must be rejected in favour of the second.
val random = ScriptedRandom(
longs = ArrayDeque(listOf(OwnStationIds.BENCH_PING, 42L)),
bytes = ArrayDeque(listOf(OwnStationIds.BENCH_PING_MAC, byteArrayOf(0x13, 1, 2, 3, 4, 5))),
)
val p = Pseudonym.generate(0L, random)
assertEquals(42L, p.stationId)
assertEquals("0x13 with the group bit cleared and the local bit set", 0x12, p.mac[0].toInt() and 0xFF)
}
@Test
fun `a rotation replaces the station id and the address together`() {
val a = Pseudonym.generate(0L, Random(1))
val b = Pseudonym.generate(Pseudonym.ROTATION_INTERVAL_MS, Random(2))
assertNotEquals(a.stationId, b.stationId)
assertFalse(a.mac.contentEquals(b.mac))
}
@Test
fun `equality compares the address bytes, not the array instance`() {
assertEquals(
Pseudonym(7L, mac(0x02), 5L),
Pseudonym(7L, mac(0x02), 5L),
)
}
private fun mac(first: Int) = byteArrayOf(first.toByte(), 0x11, 0x22, 0x33, 0x44, 0x55)
private class ScriptedRandom(
private val longs: ArrayDeque<Long>,
private val bytes: ArrayDeque<ByteArray>,
) : Random() {
override fun nextBits(bitCount: Int): Int = error("not used by Pseudonym.generate")
override fun nextLong(from: Long, until: Long): Long = longs.removeFirst()
override fun nextBytes(size: Int): ByteArray = bytes.removeFirst().copyOf()
}
}