152 lines
6.7 KiB
Kotlin
152 lines
6.7 KiB
Kotlin
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() }))
|
||
|
|
}
|
||
|
|
}
|