Commit 1029f72f0f for bind
commit 1029f72f0fabb1e53b3e61f934e1fd14de3ee01e
Author: Štěpán Balážik <stepan@isc.org>
Date: Wed Sep 16 17:31:17 2026 +0200
Test the QueryContext response life cycle
Guard the fixes for hand-rolled responses (1fc206556b), the rollback
API (5384998ccd) and rendering at send time (d11b51445a): the ans1
handlers spoil and roll back a response, send a fresh one and change a
response after rendering it, and DnsResponseSend unit tests cover the
hand-rolled and TSIG-signed cases.
Assisted-by: Claude:claude-fable-5-1
diff --git a/bin/tests/system/isctest/asyncserver/tests/response_handling/ans1/ans.py b/bin/tests/system/isctest/asyncserver/tests/response_handling/ans1/ans.py
new file mode 100644
index 0000000000..353ced1c0b
--- /dev/null
+++ b/bin/tests/system/isctest/asyncserver/tests/response_handling/ans1/ans.py
@@ -0,0 +1,92 @@
+# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+#
+# SPDX-License-Identifier: MPL-2.0
+#
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at https://mozilla.org/MPL/2.0/.
+#
+# See the COPYRIGHT file distributed with this work for additional
+# information regarding copyright ownership.
+
+from collections.abc import AsyncGenerator
+
+import dns.flags
+import dns.name
+import dns.rcode
+import dns.rdataclass
+import dns.rdatatype
+import dns.rrset
+
+from isctest.asyncserver import AsyncDnsServer, QueryContext, ResponseHandler
+from isctest.asyncserver.actions import DnsResponseSend
+from isctest.asyncserver.matchers import Qname
+
+ZONE = "response.test."
+
+
+def txt(name: dns.name.Name, text: str) -> dns.rrset.RRset:
+ return dns.rrset.from_text(
+ name, 300, dns.rdataclass.IN, dns.rdatatype.TXT, f'"{text}"'
+ )
+
+
+class FreshResponseHandler(ResponseHandler):
+ """
+ Send a response carrying the server's defaults but none of the zone data
+ prepared for the query.
+ """
+
+ matcher = Qname(f"fresh.{ZONE}")
+
+ async def get_responses(
+ self, qctx: QueryContext
+ ) -> AsyncGenerator[DnsResponseSend, None]:
+ yield DnsResponseSend(qctx.prepare_new_response(with_zone_data=False))
+
+
+class RenderedBeforeSendHandler(ResponseHandler):
+ """
+ Render the response to wire before changing it, as a handler measuring
+ its size would; the changes must still reach the client.
+ """
+
+ matcher = Qname(f"rendered.{ZONE}")
+
+ async def get_responses(
+ self, qctx: QueryContext
+ ) -> AsyncGenerator[DnsResponseSend, None]:
+ qctx.response.to_wire()
+ qctx.response.answer.append(txt(qctx.qname, "added after rendering"))
+ yield DnsResponseSend(qctx.response, authoritative=False)
+
+
+class RollbackHandler(ResponseHandler):
+ """
+ Spoil the response prepared from zone data, then discard the changes with
+ QueryContext.prepare_new_response() before sending it.
+ """
+
+ matcher = Qname(f"rollback.{ZONE}")
+
+ async def get_responses(
+ self, qctx: QueryContext
+ ) -> AsyncGenerator[DnsResponseSend, None]:
+ qctx.response.set_rcode(dns.rcode.SERVFAIL)
+ qctx.response.flags &= ~dns.flags.AA
+ qctx.response.answer.append(txt(qctx.qname, "spoiled"))
+ yield DnsResponseSend(qctx.prepare_new_response())
+
+
+def main() -> None:
+ server = AsyncDnsServer(default_aa=True, default_rcode=dns.rcode.NOTIMP)
+ server.install_response_handlers(
+ FreshResponseHandler(),
+ RenderedBeforeSendHandler(),
+ RollbackHandler(),
+ )
+ server.run()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/bin/tests/system/isctest/asyncserver/tests/response_handling/ans1/zones/response.test.db b/bin/tests/system/isctest/asyncserver/tests/response_handling/ans1/zones/response.test.db
new file mode 100644
index 0000000000..d24d0681b0
--- /dev/null
+++ b/bin/tests/system/isctest/asyncserver/tests/response_handling/ans1/zones/response.test.db
@@ -0,0 +1,8 @@
+; Zone data for the response handlers in ans.py. fresh.response.test. is
+; deliberately absent so that the zone data prepares an NXDOMAIN response for
+; it.
+response.test. 300 IN SOA ns.response.test. hostmaster.response.test. 1 3600 600 86400 300
+response.test. 300 IN NS ns.response.test.
+ns.response.test. 300 IN A 10.53.0.1
+rollback.response.test. 300 IN A 192.0.2.1
+rendered.response.test. 300 IN A 192.0.2.2
diff --git a/bin/tests/system/isctest/asyncserver/tests/response_handling/tests_response_handling.py b/bin/tests/system/isctest/asyncserver/tests/response_handling/tests_response_handling.py
new file mode 100644
index 0000000000..99b78dd5cb
--- /dev/null
+++ b/bin/tests/system/isctest/asyncserver/tests/response_handling/tests_response_handling.py
@@ -0,0 +1,88 @@
+# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+#
+# SPDX-License-Identifier: MPL-2.0
+#
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at https://mozilla.org/MPL/2.0/.
+#
+# See the COPYRIGHT file distributed with this work for additional
+# information regarding copyright ownership.
+
+import asyncio
+
+import dns.message
+import dns.rcode
+import dns.rrset
+import dns.tsig
+import pytest
+
+from isctest.asyncserver import _make_asyncserver_response
+from isctest.asyncserver.actions import DnsResponseSend
+from isctest.template import ANS1
+
+import isctest
+
+
+def query(qname: str) -> dns.message.Message:
+ msg = isctest.query.create(qname, "A", dnssec=False, rd=False)
+ return isctest.query.tcp(msg, ANS1.ip, timeout=3, attempts=1)
+
+
+def test_rollback_restores_the_response_from_zone_data():
+ res = query("rollback.response.test.")
+ isctest.check.noerror(res)
+ isctest.check.aaflag(res)
+ isctest.check.section_equal(
+ res.answer,
+ [dns.rrset.from_text("rollback.response.test.", 300, "IN", "A", "192.0.2.1")],
+ )
+
+
+def test_fresh_response_keeps_the_server_defaults():
+ res = query("fresh.response.test.")
+ isctest.check.rcode(res, dns.rcode.NOTIMP)
+ isctest.check.aaflag(res)
+ isctest.check.empty_answer(res)
+ isctest.check.empty_authority(res)
+
+
+def test_changes_after_rendering_reach_the_wire():
+ res = query("rendered.response.test.")
+ isctest.check.noerror(res)
+ isctest.check.noaaflag(res)
+ isctest.check.section_equal(
+ res.answer,
+ [
+ dns.rrset.from_text("rendered.response.test.", 300, "IN", "A", "192.0.2.2"),
+ dns.rrset.from_text(
+ "rendered.response.test.", 300, "IN", "TXT", '"added after rendering"'
+ ),
+ ],
+ )
+
+
+QUERY = dns.message.make_query("unit.test.", "A")
+
+
+def perform(action: DnsResponseSend) -> dns.message.Message | bytes | None:
+ return asyncio.run(action.perform())
+
+
+def test_hand_rolled_response_is_refused():
+ response = dns.message.make_response(QUERY)
+ action = DnsResponseSend(response)
+ with pytest.raises(RuntimeError, match="prepare_new_response"):
+ perform(action)
+ action = DnsResponseSend(response, acknowledge_hand_rolled_response=True)
+ assert perform(action) is response
+
+
+def test_aa_change_on_a_signed_response_is_refused():
+ response = _make_asyncserver_response(QUERY)
+ response.use_tsig(dns.tsig.Key("key.", "c2VjcmV0"))
+ response.to_wire()
+ assert response.tsig is not None
+ with pytest.raises(RuntimeError, match="TSIG"):
+ perform(DnsResponseSend(response, authoritative=True))
+ assert perform(DnsResponseSend(response)) is response