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.

Reading

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.

Overriding name servers

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.

MockDNSServer & Handler

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.

Disabling tests

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.

Walkthrough

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.

Let's write some tests!

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)!

Tasks

These are high level overviews. The inline comments in code marked TODO explain these in more detail.

  1. individualQuerySucceeds(): Make the tests pass by returning an A response from the root server.
  2. testDNSErrorException(): Isn't it more fun to trigger a failure and ensure we actually fail? Craft a response that makes individualQueryProcess throw a DNSErrorException
  3. 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.

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!

Sample Solution

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