package com.hawhamburg.micr0bu import com.hawhamburg.micr0bu.data.mqtt.RecvV2xMessage import com.hawhamburg.micr0bu.domain.asn1.CamUperCodec import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test /** * Pins [RecvV2xMessage] to the protobuf wire format of consider it's `RecvV2XMessage` * (`v2x_interface.proto`, V2X RX protocol v2.4.2), the envelope the CiT One publishes on its raw * `v2x/rx` topics. * * ## Where the fixtures come from * The envelope bytes are written out here by hand from the protobuf encoding rules and the field * numbers in that `.proto`, with the derivation in the comments, so a reviewer can check them * without running anything. They are deliberately **not** produced by an encoder in this * repository: a fixture generated by our own code would agree with our own reader no matter how * wrong both were, which is exactly the failure mode the ASN.1 work in this project ran into * three times. * * The CAM payload inside is the golden UPER frame from [CamEncodeGoldenTest], itself verified * against `asn1tools` and the real ETSI modules in `asn1/`. * * ## Why this matters * Field numbers are wire-format constants with no self-describing names on the wire. Reading * field 2 where the schema says field 3 does not fail loudly, it silently yields a plausible * looking byte string that decodes to nothing. These tests are what should fail if the constants * in [RecvV2xMessage] are ever "tidied". */ class RecvV2xMessageTest { /** * The golden CAM UPER, 43 bytes, from [CamEncodeGoldenTest]. Its ItsPduHeader reads * protocolVersion 2, messageID 2 (CAM), stationID 0x000f423f = 999999. */ private val goldenCam = "0202000f423f3700402ab215af6e286477dffffffc23b7743e0027ffc0d0fe0118329337feebfff6000000" /** * A complete `RecvV2XMessage` carrying [goldenCam], byte by byte: * * ``` * 0a 05 field 1 (btpHeader), length-delimited, 5 bytes * 08 02 field 1 (type) varint = 2, CAM * 10 d1 0f field 2 (destinationPort) varint = 2001 * 12 07 field 2 (gnHeader), length-delimited, 7 bytes * 42 05 field 8 (dest), length-delimited, 5 bytes * 0a 03 field 1 (area), length-delimited, 3 bytes * 18 f4 03 field 3 (distA) varint = 500 metres * 1a 2b field 3 (payload), length-delimited, 0x2b = 43 bytes * ``` */ private val camEnvelope = "0a05080210d10f120742050a0318f4031a2b" + goldenCam private fun String.hexToBytes(): ByteArray = chunked(2).map { it.toInt(16).toByte() }.toByteArray() // ---- the happy path -------------------------------------------------------------------- @Test fun `parses btp header, geo radius and payload from a full envelope`() { val msg = RecvV2xMessage.parse(camEnvelope.hexToBytes()) assertNotNull("envelope should parse", msg) msg!! assertEquals("btpHeader.type: CAM", 2, msg.pduType) assertEquals("btpHeader.destinationPort", 2001, msg.destinationPort) assertEquals("gnHeader.dest.area.distA, metres", 500, msg.destAreaRadiusM) assertTrue( "payload must be the CAM UPER byte for byte", msg.payload.contentEquals(goldenCam.hexToBytes()), ) } @Test fun `extracted payload is decodable UPER, not a mangled copy`() { val msg = RecvV2xMessage.parse(camEnvelope.hexToBytes())!! // The whole point of carrying bytes rather than a String through the MQTT layer: a UTF-8 // round trip would replace most of these bytes and this decode would fail. val cam = CamUperCodec.decode(msg.payload, receivedAtEpochMs = 1_787_100_000_000L) assertNotNull("payload should decode as a CAM", cam) assertEquals("stationID from the ItsPduHeader", 999_999L, cam!!.stationId) } @Test fun `reads a DENM envelope's relevance radius`() { // Same shape, DENM values: type 1, port 2002, distA 1000 m, a 2-byte stand-in payload. // 0a 05 08 01 10 d2 0f | 12 07 42 05 0a 03 18 e8 07 | 1a 02 02 01 val msg = RecvV2xMessage.parse("0a05080110d20f120742050a0318e8071a020201".hexToBytes()) assertNotNull(msg) assertEquals(1, msg!!.pduType) assertEquals(2002, msg.destinationPort) assertEquals(1000, msg.destAreaRadiusM) } // ---- forward compatibility ------------------------------------------------------------- @Test fun `skips unknown fields and does not depend on field order`() { // payload first, then an unknown varint (field 7) and an unknown fixed32 (field 6) that // this schema revision does not define, then the btpHeader. Protobuf permits all three, // and a reader that assumed order or choked on unknowns would break the first time // consider it added a field. val bytes = ("1a2b" + goldenCam + "38b96035deadbeef0a05080210d10f").hexToBytes() val msg = RecvV2xMessage.parse(bytes) assertNotNull(msg) assertEquals(2, msg!!.pduType) assertEquals(2001, msg.destinationPort) assertTrue(msg.payload.contentEquals(goldenCam.hexToBytes())) } @Test fun `accepts an envelope carrying nothing but a payload`() { val msg = RecvV2xMessage.parse(("1a2b" + goldenCam).hexToBytes()) assertNotNull(msg) assertNull("no btpHeader was sent", msg!!.pduType) assertNull("no gnHeader was sent", msg.destAreaRadiusM) assertTrue(msg.payload.contentEquals(goldenCam.hexToBytes())) } // ---- malformed input ------------------------------------------------------------------- // These arrive off a network topic. A reader that throws takes the MQTT callback thread with // it, so every one of these must return null instead. @Test fun `returns null for a truncated envelope`() { val full = camEnvelope.hexToBytes() assertNull(RecvV2xMessage.parse(full.copyOfRange(0, full.size / 2))) } @Test fun `returns null when the payload field is present but empty`() { assertNull(RecvV2xMessage.parse("1a00".hexToBytes())) } @Test fun `returns null when there is no payload field at all`() { assertNull(RecvV2xMessage.parse("0a05080210d10f".hexToBytes())) } @Test fun `returns null for empty input and for bytes that are not protobuf`() { assertNull(RecvV2xMessage.parse(ByteArray(0))) // A run of continuation bytes: a varint that never terminates, which is what would walk // an unguarded reader off the end of the buffer. assertNull(RecvV2xMessage.parse(ByteArray(24) { 0xFF.toByte() })) } }