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, private val bytes: ArrayDeque, ) : 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() } }