Test the DNS filter decision flow without Android (FR-18)

A blocked host becomes an NXDOMAIN reply addressed to the querying client.
The VPN consent dialog stays manual; this covers the runtime-verifiable core.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-10-13 20:28:11 +11:00
parent f5e240f27e
commit 1d75b0ef9c

View File

@@ -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()
}
}