diff --git a/android/app/src/test/java/au/audasmedia/webrestrict/kids/DnsFilterFlowTest.kt b/android/app/src/test/java/au/audasmedia/webrestrict/kids/DnsFilterFlowTest.kt new file mode 100644 index 0000000..61d3a14 --- /dev/null +++ b/android/app/src/test/java/au/audasmedia/webrestrict/kids/DnsFilterFlowTest.kt @@ -0,0 +1,79 @@ +package au.audasmedia.webrestrict.kids + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.ByteArrayOutputStream + +/** + * The VPN's decision path without Android: a DNS query packet goes in, and a + * blocked host comes out as an NXDOMAIN reply to the exact client that asked. + * This is the runtime-verifiable part of FR-18; the consent dialog itself is + * manual (see android/README.md). + */ +class DnsFilterFlowTest { + + private val clientIp = byteArrayOf(10, 111, 222.toByte(), 1) // interface address + private val dnsIp = byteArrayOf(10, 111, 222.toByte(), 2) // fake resolver + + @Test + fun `a blocked host becomes an NXDOMAIN answer`() { + val reply = evaluate("www.youtube.com", DeviceConfig(locked = true, block = listOf("youtube.com"))) + assertTrue(reply != null) + + val udp = IpPacket.parseUdp(reply!!, reply.size)!! + // The reply is addressed back to the client, from the resolver. + assertEquals(53, udp.srcPort) + assertEquals(40000, udp.dstPort) + assertEquals(0x03, udp.payload[3].toInt() and 0x0F) // RCODE = NXDOMAIN + assertEquals(0x80, udp.payload[2].toInt() and 0x80) // QR = 1 + } + + @Test + fun `an allowlisted host is not blocked`() { + val config = DeviceConfig( + locked = true, + block = listOf("youtube.com"), + allow = listOf("safe.youtube.com"), + ) + assertFalse(SiteFilter.isBlocked("safe.youtube.com", config)) + } + + @Test + fun `nothing is blocked while unlocked`() { + val config = DeviceConfig(locked = false, block = listOf("youtube.com")) + assertFalse(SiteFilter.isBlocked("www.youtube.com", config)) + } + + /** + * Runs the same steps [SiteFilterVpnService.handle] runs, minus the socket: + * parse the query, decide, and build the reply. It returns null when the + * host is allowed (the real service would forward it upstream instead). + */ + private fun evaluate(host: String, config: DeviceConfig): ByteArray? { + val query = dnsQuery(host) + val clientPort = 40000 + val packet = IpPacket.buildUdp(clientIp, dnsIp, clientPort, 53, query) + + val udp = IpPacket.parseUdp(packet, packet.size)!! + val question = DnsMessage.parseQuestion(udp.payload, udp.payload.size)!! + assertEquals(host, question.name) + + if (!SiteFilter.isBlocked(question.name, config)) return null + val answer = DnsMessage.nxdomain(udp.payload, question.end) + return IpPacket.buildUdp(udp.dstIp, udp.srcIp, udp.dstPort, udp.srcPort, answer) + } + + private fun dnsQuery(name: String): ByteArray { + val out = ByteArrayOutputStream() + out.write(byteArrayOf(0x12, 0x34, 0x01, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0)) + for (label in name.split('.')) { + out.write(label.length) + out.write(label.toByteArray(Charsets.US_ASCII)) + } + out.write(0) + out.write(byteArrayOf(0x00, 0x01, 0x00, 0x01)) + return out.toByteArray() + } +}