This tutorial is to help you write your own system tests for the DNSLookupService
in PA2. Writing tests is one way to make sure your new changes to code do not break the logic you have already implemented.
First, download DNSQueryResponseTest.java into <PA2-starter>/test/ca/ubc/cs/cs317/dnslookup
. It is strongly recommended to use an IDE (like IntelliJ). Once copied, you should be able to run all the tests in DNSQueryResponseTest
from the Project
tool window on the left.
Note: For the duration of the tutorial, you will be using a different DEFAULT_DNS_PORT
in DNSLookupService.java
. Port 53 is restricted, and you cannot typically bind to port 53 without root. Technically, you can, with some extra work. For the sake of simplicity, let's change the port to a value in the dynamic ports range 49152 - 65535
, say 53530.
To implement a test system for DNSLookupService
, we need to intercept DNS queries from DNSLookupService
and respond to these queries with our hand-crafted response messages. The very first query from DNSLookupService
is to one of the root servers and this means we need to provide our own "root" server implementation. Luckily, DNSCache
provides a way to override the root. Once we are in control of the root, we can craft response payloads that point to more nameservers that we create and control.
AutoCloseable
Our classes MockDNSServer
and Handler
implement AutoCloseable
. When these classes are instantiated in a try-with-resources block in Java, they are automatically cleaned up. If you want more fine-grained control over these types, you may construct them and manually invoke obj.close()
when you're finished with them.
You can construct a MockDNSServer
with your own address as long as it is on the loopback interface. Look up the implementation of iterativeQuerySucceeds()
for inspiration. Once you have a MockDNSServer, you can invoke MockDNSServer.withHandler
with a function of type Function<DNSMessage, DNSMessage>
- this is a handler function that takes a DNSMessage
query and returns a DNSMessage
response.
As long as the Handler
and MockDNSServer
objects are alive, the handler function is invoked on all of the DNS queries this MockDNSServer
receives and the response is sent back to DNSLookupService
. This happens in a background thread, infinitely forever, until the Handler
is destroyed.
If you extend Handler
or implement custom logic in your handler functions, ensure you do not introduce any race conditions on objects shared between your main thread and these handler functions.
You may be in the middle of implementing PA2 and your DNSLookupService
may not have all of its logic in place. That's completely fine! Comment out the tests for methods that you have not yet implemented in DNSLookupService
.
Let's walk through the test individualQueryFails()
and see how a handler is created and used. Then, let's identify how the chain of mock nameservers is created in iterativeQuerySucceeds()
. Ignore any comments marked TODO
- that's for the next part.
Once you get a hang of how the existing tests work, start working on implementing the tasks marked with a TODO
. Feel free to modify the files as you wish (after all, these are your tests!).
Wireshark: Keep wireshark running in the background on your loopback interface with the filter udp.port == 53530
. It's a great way to debug your tests by looking at the packets on wireshark. Wireshark even highlights malformed DNS packets (like stray bytes at the end of your DNSMessage)!
These are high level overviews. The inline comments in code marked TODO
explain these in more detail.
individualQuerySucceeds()
: Make the tests pass by returning an A
response from the root server.testDNSErrorException()
: Isn't it more fun to trigger a failure and ensure we actually fail? Craft a response that makes individualQueryProcess
throw a DNSErrorException
iterativeQuerySucceeds()
: The final boss battle! In the previous tutorials, we have seen DNS queries in wireshark that return more than one nameserver resource records in the response message. Let's simulate that by adding one more name server almost like comNameServer
and comHandler
. This newly added nameserver record must also have a server listening at that address.sudo ifconfig lo0 alias 127.0.0.[PUT YOUR VALUE HERE] up
)Once you have reached this point, you should be well equipped to write your own tests for PA2 and beyond. Once again, the importance of well-crafted tests cannot be stressed enough. Writing tests is important for programming assignments in 317 as well as when you're out in the real world writing software!
NOTE: We provided sample solutions just for reference. The main point of this tutorial is to show how we can test a client implementation by using mock servers. So don't just copy the code without understanding it! You want to test your code with more test cases. For instance, what if I have no IP addresses available for the nameservers? We didn't really test our cache here; what about our cache?
@Test
public void individualQueryFails() {
Handler handler = server.withHandler((DNSMessage received) -> {
System.out.printf("Received %s\n", received);
DNSMessage message = new DNSMessage((short) received.getID());
message.setQR(true);
message.addQuestion(received.getQuestion());
// message.setRcode(2 /* SERVFAIL */);
// We put no RRs here
System.out.printf("Sending %s\n", message);
return message;
});
try {
DNSQuestion question = new DNSQuestion("google.com", RecordType.A, RecordClass.IN);
Set<ResourceRecord> records = service.individualQueryProcess(question, root);
Assertions.assertNull(records);
} catch (DNSLookupService.DNSErrorException e) {
// Nothing
}
handler.close();
}
@Test
public void individualQuerySucceeds() {
Handler handler = server.withHandler((DNSMessage received) -> {
// Reply packet
DNSMessage reply = new DNSMessage((short) received.getID());
// Question
DNSQuestion question = received.getQuestion();
reply.addQuestion(question);
reply.setQR(true);
reply.setRcode(0);
// Resource Records
try {
CommonResourceRecord rr = new CommonResourceRecord(
question,
600,
InetAddress.getByName("1.2.3.4")
);
reply.addResourceRecord(rr);
} catch (UnknownHostException e) {
// Do nothing
}
return reply;
});
try {
DNSQuestion question = new DNSQuestion("google.com", RecordType.A, RecordClass.IN);
Set<ResourceRecord> records = service.individualQueryProcess(question, root);
Assertions.assertNotNull(records);
try {
CommonResourceRecord rr = new CommonResourceRecord(
question,
600,
InetAddress.getByAddress(new byte[]{1,2,3,4})
);
Assertions.assertNotEquals(0, records.size());
if (!records.contains(rr)) {
Assertions.fail("Should contain RR with A: 1,2,3,4");
}
// Assertions.assertTrue(records.contains(rr)); <- this one does not return very descriptive error message
} catch (UnknownHostException e) {
Assertions.fail("UnknownHost Exceptions");
}
} catch (DNSLookupService.DNSErrorException e) {
Assertions.fail("Should not have thrown");
}
handler.close();
}
@Test
public void testDNSErrorException() {
try (
Handler ignored = server.withHandler((DNSMessage received) -> {
/* TODO: Modify this to return a response such that it triggers a DNSErrorException
* in `individualQueryProcess` */
// Reply packet
DNSMessage reply = new DNSMessage((short) received.getID());
// Question
DNSQuestion question = received.getQuestion();
reply.addQuestion(question);
reply.setQR(true);
// Set RCode to 1, our code should complain the RCode=1 with an Exception!
reply.setRcode(1);
return reply;
});
) {
DNSQuestion question = new DNSQuestion("google.com", RecordType.A, RecordClass.IN);
service.individualQueryProcess(question, root);
Assertions.fail("Expected the previous line to have thrown an exception");
} catch (DNSLookupService.DNSErrorException e) {
Assertions.assertEquals("RCode is 1", e.getMessage());
}
}
@Test
public void iterativeQuerySucceeds() throws SocketException, UnknownHostException {
InetAddress comNSAddress = InetAddress.getByAddress(new byte[]{127, 0, 0, 21});
InetAddress googleComNSAddress = InetAddress.getByAddress(new byte[]{127,0,0,112});
Handler rootHandler = server.withHandler((DNSMessage received) -> {
DNSMessage response = new DNSMessage((short) received.getID());
DNSQuestion question = received.getQuestion();
response.addQuestion(question);
response.setQR(true);
/* TODO: Add another NS record `ns1.google.com` and map it to a new IP address on
* the loopback interface; for instance, 127.0.0.112 */
response.addResourceRecord(new CommonResourceRecord(
new DNSQuestion("com", RecordType.NS, RecordClass.IN),
600, "ns1.google.com"), "nameserver");
CommonResourceRecord NSRecord1 = new CommonResourceRecord(
new DNSQuestion("ns1.google.com", RecordType.A, RecordClass.IN),
600, googleComNSAddress
);
response.addResourceRecord(NSRecord1, "additional");
response.addResourceRecord(new CommonResourceRecord(
new DNSQuestion("com", RecordType.NS, RecordClass.IN),
600, "ns.google.com"), "nameserver");
response.addResourceRecord(new CommonResourceRecord(
new DNSQuestion("ns.google.com", RecordType.A, RecordClass.IN)
, 600, comNSAddress), "additional");
return response;
});
MockDNSServer comNameServer = new MockDNSServer(comNSAddress);
Handler comHandler = comNameServer.withHandler((DNSMessage received) -> {
DNSMessage response = new DNSMessage((short) received.getID());
DNSQuestion question = received.getQuestion();
response.addQuestion(question);
response.setQR(true);
try {
response.addResourceRecord(new CommonResourceRecord(question, 600,
InetAddress.getByAddress(new byte[]{8, 8, 8, 8})));
return response;
} catch (UnknownHostException e) {
throw new RuntimeException(e);
}
});
/* TODO: Spawn another MockDNSServer and register a handler that returns 8.8.4.4 for the question `google.com` */
// This is similar to the above name server
MockDNSServer com1NSServer = new MockDNSServer(googleComNSAddress);
Handler com1NSHandler = com1NSServer.withHandler((DNSMessage received) -> {
DNSMessage response = new DNSMessage((short) received.getID());
DNSQuestion question = received.getQuestion();
response.addQuestion(question);
response.setQR(true);
try {
response.addResourceRecord(new CommonResourceRecord(question, 600,
InetAddress.getByAddress(new byte[]{8,8,4,4}))); // <- notice we use 8.8.4.4
return response;
} catch (UnknownHostException e) {
throw new RuntimeException(e);
}
});
try {
DNSQuestion question = new DNSQuestion("google.com", RecordType.A, RecordClass.IN);
Collection<CommonResourceRecord> records = service.iterativeQuery(question);
Assertions.assertFalse(records.isEmpty());
/* TODO: Verify here that `records` includes either 8.8.8.8 or 8.8.4.4 as an answer for google.com */
Assertions.assertNotNull(records);
CommonResourceRecord rr44 = new CommonResourceRecord(question, 600,
InetAddress.getByAddress(new byte[]{8,8,4,4}));
CommonResourceRecord rr88 = new CommonResourceRecord(question, 600,
InetAddress.getByAddress(new byte[]{8,8,8,8}));
if (!records.contains(rr44) && !records.contains(rr88)) {
Assertions.fail("Did not find RRs with address 8.8.4.4 or 8.8.8.8. Should get at least one");
}
} catch (DNSLookupService.DNSErrorException e) {
Assertions.fail("Should not have thrown");
}
comHandler.close();
rootHandler.close();
/* TODO: Clean up the resources you have created */
comNameServer.close();
com1NSServer.close();
com1NSHandler.close();
}
}