Tests/LibDNS: Make TestDNSResolver independent of live DNS

Problem: TestDNSResolver resolved google.com against 1.1.1.1 over the
real network. Whenever a CI runner couldn’t reach external DNS, the
lookup returned no records, and the test failed.

Fix: Stand up a local UDP and TCP server on 127.0.0.1 that answers the
query with a canned A and AAAA record, and point the resolver at it.
This commit is contained in:
sideshowbarker 2026-06-17 16:21:12 +09:00 committed by Alexander Kalenik
parent d6014c8869
commit 6757971d59
2 changed files with 120 additions and 67 deletions

View file

@ -4,5 +4,5 @@ set(TEST_SOURCES
)
foreach(source IN LISTS TEST_SOURCES)
ladybird_test("${source}" LibDNS LIBS LibDNS LibTLS)
ladybird_test("${source}" LibDNS LIBS LibDNS)
endforeach()

View file

@ -4,28 +4,59 @@
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <AK/ByteBuffer.h>
#include <AK/Endian.h>
#include <AK/IPv4Address.h>
#include <AK/IPv6Address.h>
#include <AK/MemoryStream.h>
#include <LibCore/EventLoop.h>
#include <LibCore/Socket.h>
#include <LibCore/TCPServer.h>
#include <LibCore/UDPServer.h>
#include <LibDNS/Resolver.h>
#include <LibTLS/TLSv12.h>
#include <LibTest/TestCase.h>
TEST_CASE(test_udp)
#include <AK/Windows.h>
namespace {
// Builds a canned DNS response for a query: It echoes the question section and answers with one fixed A and one fixed
// AAAA record. The resolver correlates responses to lookups by header ID, and reads the answer section. Enough to drive
// a successful lookup while exercising the real wire encode and decode paths — without depending on a live DNS server.
ErrorOr<ByteBuffer> build_dns_response(ReadonlyBytes query_bytes)
{
Core::EventLoop loop;
FixedMemoryStream stream { query_bytes };
auto query = TRY(DNS::Messages::Message::from_raw(stream));
DNS::Resolver resolver {
[&] -> ErrorOr<DNS::Resolver::SocketResult> {
Core::SocketAddress addr = { IPv4Address::from_string("1.1.1.1"sv).value(), static_cast<u16>(53) };
return DNS::Resolver::SocketResult {
TRY(Core::BufferedSocket<Core::UDPSocket>::create(TRY(Core::UDPSocket::connect(addr)))),
DNS::Resolver::ConnectionMode::UDP,
};
DNS::Messages::Message response;
response.header.id = query.header.id;
response.header.options.set_recursion_available(true);
response.header.options.set_response_code(DNS::Messages::Options::ResponseCode::NoError);
response.header.question_count = query.questions.size();
response.questions = move(query.questions);
auto name = response.questions.is_empty()
? DNS::Messages::DomainName::from_string("example.com"sv)
: response.questions.first().name;
response.answers.append(DNS::Messages::ResourceRecord {
name, DNS::Messages::ResourceType::A, DNS::Messages::Class::IN, 300,
DNS::Messages::Records::A { IPv4Address { 192, 0, 2, 1 } }, {} });
response.answers.append(DNS::Messages::ResourceRecord {
name, DNS::Messages::ResourceType::AAAA, DNS::Messages::Class::IN, 300,
DNS::Messages::Records::AAAA { IPv6Address::loopback() }, {} });
response.header.answer_count = response.answers.size();
ByteBuffer out;
TRY(response.to_raw(out));
return out;
}
};
void expect_successful_lookup(DNS::Resolver& resolver, Core::EventLoop& loop)
{
TRY_OR_FAIL(resolver.when_socket_ready()->await());
resolver.lookup("google.com", DNS::Messages::Class::IN, { DNS::Messages::ResourceType::A, DNS::Messages::ResourceType::AAAA })
resolver.lookup("example.com", DNS::Messages::Class::IN, { DNS::Messages::ResourceType::A, DNS::Messages::ResourceType::AAAA })
->when_resolved([&](auto& result) {
EXPECT(!result->records().is_empty());
loop.quit(0);
@ -38,40 +69,94 @@ TEST_CASE(test_udp)
EXPECT_EQ(0, loop.exec());
}
}
TEST_CASE(test_udp)
{
Core::EventLoop loop;
auto server = Core::UDPServer::construct();
EXPECT(server->bind(IPv4Address { 127, 0, 0, 1 }, 0));
auto server_port = server->local_port().value();
server->on_ready_to_receive = [&] {
sockaddr_in from {};
auto query = MUST(server->receive(4096, from));
auto response = MUST(build_dns_response(query.bytes()));
MUST(server->send(response.bytes(), from));
};
DNS::Resolver resolver {
[server_port] -> ErrorOr<DNS::Resolver::SocketResult> {
Core::SocketAddress address { IPv4Address { 127, 0, 0, 1 }, server_port };
return DNS::Resolver::SocketResult {
TRY(Core::BufferedSocket<Core::UDPSocket>::create(TRY(Core::UDPSocket::connect(address)))),
DNS::Resolver::ConnectionMode::UDP,
};
}
};
expect_successful_lookup(resolver, loop);
}
TEST_CASE(test_tcp)
{
Core::EventLoop loop;
auto server = MUST(Core::TCPServer::try_create());
MUST(server->listen(IPv4Address { 127, 0, 0, 1 }, 0, Core::TCPServer::AllowAddressReuse::Yes));
auto server_port = server->local_port().value();
Vector<NonnullOwnPtr<Core::TCPSocket>> connections;
ByteBuffer inbox;
server->on_ready_to_accept = [&] {
// The listening socket is non-blocking — so a readiness notification can outpace the connection. Tolerate
// accept() reporting nothing is ready yet.
auto accepted = server->accept();
if (accepted.is_error())
return;
connections.append(accepted.release_value());
auto& socket = *connections.last();
socket.on_ready_to_read = [&socket, &inbox] {
// The accepted socket is non-blocking and TCP is a stream — so read what is available, and accumulate it
// until the framed message is complete.
u8 chunk[1024];
auto read = socket.read_some({ chunk, sizeof(chunk) });
if (read.is_error() || inbox.try_append(read.value()).is_error())
return;
// DNS over TCP frames each message with a 2-byte length prefix.
if (inbox.size() < sizeof(u16))
return;
u16 const message_size = (static_cast<u16>(inbox[0]) << 8) | inbox[1];
if (inbox.size() < sizeof(u16) + message_size)
return;
auto response = MUST(build_dns_response(inbox.bytes().slice(sizeof(u16), message_size)));
ByteBuffer framed;
NetworkOrdered<u16> framed_size = response.size();
MUST(framed.try_append(&framed_size, sizeof(framed_size)));
MUST(framed.try_append(response));
MUST(socket.write_until_depleted(framed.bytes()));
inbox.clear();
};
};
DNS::Resolver resolver {
[&] -> ErrorOr<DNS::Resolver::SocketResult> {
Core::SocketAddress addr = { IPv4Address::from_string("1.1.1.1"sv).value(), static_cast<u16>(53) };
auto tcp_socket = TRY(Core::TCPSocket::connect(addr));
TRY(tcp_socket->set_blocking(false));
[server_port] -> ErrorOr<DNS::Resolver::SocketResult> {
Core::SocketAddress address { IPv4Address { 127, 0, 0, 1 }, server_port };
auto socket = TRY(Core::TCPSocket::connect(address));
TRY(socket->set_blocking(false));
return DNS::Resolver::SocketResult {
TRY(Core::BufferedSocket<Core::TCPSocket>::create(move(tcp_socket))),
TRY(Core::BufferedSocket<Core::TCPSocket>::create(move(socket))),
DNS::Resolver::ConnectionMode::TCP,
};
}
};
TRY_OR_FAIL(resolver.when_socket_ready()->await());
resolver.lookup("google.com", DNS::Messages::Class::IN, { DNS::Messages::ResourceType::A, DNS::Messages::ResourceType::AAAA })
->when_resolved([&](auto& result) {
EXPECT(!result->records().is_empty());
loop.quit(0);
})
.when_rejected([&](auto& error) {
outln("Failed to resolve: {}", error);
loop.quit(1);
});
EXPECT_EQ(0, loop.exec());
expect_successful_lookup(resolver, loop);
}
// https://www.rfc-editor.org/rfc/rfc6761#section-6.3
TEST_CASE(test_localhost_resolves_to_loopback_without_a_socket)
{
Core::EventLoop loop;
@ -109,35 +194,3 @@ TEST_CASE(test_localhost_resolves_to_loopback_without_a_socket)
// A multi-label subdomain: the case that the host resolver / upstream server may not map to loopback.
expect_loopback("test-host.localhost"sv);
}
TEST_CASE(test_tls)
{
Core::EventLoop loop;
DNS::Resolver resolver {
[&] -> ErrorOr<DNS::Resolver::SocketResult> {
Core::SocketAddress addr = { IPv4Address::from_string("1.1.1.1"sv).value(), static_cast<u16>(853) };
TLS::Options options = {};
return DNS::Resolver::SocketResult {
MaybeOwned<Core::Socket>(TRY(TLS::TLSv12::connect(addr, "1.1.1.1", move(options)))),
DNS::Resolver::ConnectionMode::TCP,
};
}
};
TRY_OR_FAIL(resolver.when_socket_ready()->await());
resolver.lookup("google.com", DNS::Messages::Class::IN, { DNS::Messages::ResourceType::A, DNS::Messages::ResourceType::AAAA })
->when_resolved([&](auto& result) {
EXPECT(!result->records().is_empty());
loop.quit(0);
})
.when_rejected([&](auto& error) {
outln("Failed to resolve: {}", error);
loop.quit(1);
});
EXPECT_EQ(0, loop.exec());
}