Repository: AnalogJ/lexicon

Issue:
DuckDNS.org API support
The DuckDNS.org is a popular Dynamic DNS provider. And it supports the TXT records update which is used for issuing ACME certificates. Here is a sample https://github.com/acmesh-official/acme.sh/blob/master/dnsapi/dns_duckdns.sh
Could you please support it's API?



Discussion:
I'm preparing a PR for it; the API is quite simple, but since it only supports an "/update" endpoint the rest of the provider uses dnspython for all the other checks.

The main code is ready, but since I already had to skip a lot of tests due to Duck DNS not supporting many DNS features I'm working on mocking dnspython as little as possibile to have sane tests with vcrpy.

Resolving patch:
diff --git a/lexicon/providers/duckdns.py b/lexicon/providers/duckdns.py
new file mode 100644
--- /dev/null
+++ b/lexicon/providers/duckdns.py
@@ -0,0 +1,261 @@
+"""\
+Module provider for Duck DNS
+
+The Duck DNS public API does not provide the whole set of features
+supported by Lexicon: it only has one GET endpoint, "/update", authenticated by
+the token which is given when you register with one of the supported
+OAuth providers; the API only supports A, AAAA and TXT records, and
+there is no other property that can be changed, like the TTL for these records;
+the response message is plain text with "OK" or "KO" and only adds minimal
+information when you supply the "verbose" parameter.
+
+The DNS implementation supports only one record of each type
+for each registered subdomain, and the value is propagated to every subdomain
+after that, e.g. queries for testlexicon.duckdns.org and
+example.given.testlexicon.duckdns.org return the same value.
+
+Quirks of the DNS implementation:
+* The A and AAAA records can be created separately, but when you delete one
+the other is also deleted.
+* When the A or AAAA records exist the TXT record is always present. Even if
+you delete it will still be present with the value "". The implementation of
+_list_records and _delete_record does not handle this special case.\
+"""
+import logging
+
+import requests
+
+from lexicon.exceptions import AuthenticationError
+from lexicon.providers.base import Provider as BaseProvider
+
+try:
+    import dns.name
+    import dns.resolver
+except ImportError:
+    pass
+
+LOGGER = logging.getLogger(__name__)
+
+NAMESERVER_DOMAINS = ["ca-central-1.compute.amazonaws.com"]
+
+
+def provider_parser(subparser):
+    """Module provider for Duck DNS"""
+    subparser.add_argument("--auth-token", help="specify the account token for authentication")
+
+
+class Provider(BaseProvider):
+    """Provider class for Duck DNS"""
+
+    def __init__(self, config):
+        super(Provider, self).__init__(config)
+        self.api_url = "https://www.duckdns.org"
+
+    @staticmethod
+    def _get_duckdns_domain(domain):
+        domain_dns = dns.name.from_text(domain)
+
+        if len(domain_dns.labels) > 2 and b"duckdns" not in domain_dns.labels:
+            raise Exception("{} is not a valid Duck DNS domain.".format(domain))
+
+        if b"duckdns" in domain_dns.labels:
+            # use only the third level domain, the one actually registered in Duck DNS
+            domain_index = domain_dns.labels.index(b"duckdns") - 1
+        else:
+            # use the last name if it is relative
+            domain_index = -2
+
+        return domain_dns.labels[domain_index].decode("utf-8")
+
+    @staticmethod
+    def _get_duckdns_rtype_param(rtype):
+        if rtype not in ["A", "AAAA", "TXT"]:
+            raise Exception("Duck DNS only supports A, AAAA and TXT records.")
+
+        if rtype == "A":
+            return "ip"
+        elif rtype == "AAAA":
+            return "ipv6"
+        elif rtype == "TXT":
+            return "txt"
+
+    @staticmethod
+    def _get_dns_record(name, rtype):
+        if rtype not in ["A", "AAAA", "TXT"]:
+            raise Exception("Duck DNS only supports A, AAAA and TXT records.")
+
+        try:
+            dns_rrset = Provider._get_dns_rrset(name, rtype)
+        except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN):
+            return {}
+
+        ttl = dns_rrset.ttl
+
+        if rtype == "A":
+            content = dns_rrset[0].address
+        elif rtype == "AAAA":
+            content = dns_rrset[0].address
+        else:
+            content = dns_rrset[0].strings[0].decode("utf-8")
+
+        record = {
+            "id": rtype,
+            "type": rtype,
+            "name": name,
+            "ttl": ttl,
+            "content": content,
+        }
+        return record
+
+    @staticmethod
+    def _get_dns_rrset(name, rtype):
+        resolver = dns.resolver.Resolver()
+        resolver.lifetime = 60
+        resolver.timeout = 70
+        return resolver.resolve(name, rtype).rrset
+
+    def _authenticate(self):
+        if self._get_provider_option("auth_token") is None:
+            raise AuthenticationError("Must provide account token")
+
+    # Create record. If the record already exists with the same content, do nothing"
+    def _create_record(self, rtype, name, content):
+        if self._get_lexicon_option("ttl"):
+            LOGGER.warning("create_record: Duck DNS does not support modifying the TTL, ignoring {}".format(self._get_lexicon_option("ttl")))
+
+        if rtype not in ["A", "AAAA", "TXT"]:
+            raise Exception("Duck DNS only supports A, AAAA and TXT records.")
+
+        if rtype == "A" and content == "auto":
+            duckdns_content = ""
+        else:
+            duckdns_content = content
+
+        params = {
+            "domains": Provider._get_duckdns_domain(self.domain),
+            Provider._get_duckdns_rtype_param(rtype): duckdns_content
+        }
+
+        result = self._api_call(params)
+
+        LOGGER.debug("create_record: %s", result)
+        return "OK" in result
+
+    # List all records. Return an empty list if no records found
+    # type, name and content are used to filter records.
+    # If possible filter during the query, otherwise filter after response is received.
+    def _list_records(self, rtype=None, name=None, content=None):
+        duckdns_domain = Provider._get_duckdns_domain(self.domain)
+        full_duckdns_domain = duckdns_domain + ".duckdns.org"
+
+        records = [Provider._get_dns_record(full_duckdns_domain, rtype) for rtype in ["A", "AAAA", "TXT"]]
+        processed_records = [
+            {
+                "id": record["id"],
+                "type": record["type"],
+                "name": self._full_name(name) if name is not None else record["name"],
+                "ttl": record["ttl"],
+                "content": record["content"],
+            }
+            for record in records
+            if "id" in record
+        ]
+        filtered_records = [
+            record
+            for record in processed_records
+            if (
+                (rtype is None or record["type"] == rtype)
+                and (name is None or record["name"] == self._full_name(name))
+                and (content is None or record["content"] == content)
+            )
+        ]
+
+        LOGGER.debug("list_records: %s", filtered_records)
+        return filtered_records
+
+    # Create or update a record.
+    def _update_record(self, identifier, rtype=None, name=None, content=None):
+        if self._get_lexicon_option("ttl"):
+            LOGGER.warning("update_record: Duck DNS does not support modifying the TTL, ignoring {}".format(self._get_lexicon_option("ttl")))
+
+        if not identifier:
+            identifier = self._get_record_identifier(rtype=rtype, name=name)
+
+        if rtype == "A" and content == "auto":
+            duckdns_content = ""
+        else:
+            duckdns_content = content
+
+        params = {
+            "domains": Provider._get_duckdns_domain(self.domain),
+            Provider._get_duckdns_rtype_param(identifier): duckdns_content
+        }
+
+        result = self._api_call(params)
+
+        LOGGER.debug("update_record: %s", result)
+        return "OK" in result
+
+    # Delete an existing record.
+    # If record does not exist, do nothing.
+    def _delete_record(self, identifier=None, rtype=None, name=None, content=None):
+        if not identifier:
+            identifier = self._get_record_identifier(
+                rtype=rtype, name=name, content=content, delete=True
+            )
+        if identifier is None:
+            LOGGER.debug("delete_record: no record found")
+            return True
+
+        params = {
+            "domains": Provider._get_duckdns_domain(self.domain),
+            Provider._get_duckdns_rtype_param(identifier): "",
+            "clear": "true"
+        }
+        result = self._api_call(params)
+
+        LOGGER.debug("delete_record: %s", result)
+        return "OK" in result
+
+    # Helpers
+    def _api_call(self, params):
+        response = self._request(query_params=dict(params.items()))
+
+        content = response.content.decode("utf-8")
+
+        if "KO" in content:
+            raise Exception("KO: {}".format(params))
+
+        return content
+
+    def _full_name(self, record_name):
+        record_name = record_name.rstrip(".")
+        full_domain = Provider._get_duckdns_domain(self.domain) + ".duckdns.org"
+
+        if not record_name.endswith(full_domain):
+            record_name = f"{record_name}.{full_domain}"
+        return record_name
+
+    def _get_record_identifier(self, rtype=None, name=None, content=None, delete=False):
+        records = self._list_records(rtype=rtype, name=name, content=content)
+        if len(records) == 1:
+            return records[0]["id"]
+
+        if delete and len(records) == 0:
+            return None
+        else:
+            raise Exception("Unambiguous record could not be found.")
+
+    def _request(self, action="GET", url="/", data=None, query_params=None):
+        if query_params is None:
+            query_params = {}
+        query_params["token"] = self._get_provider_option("auth_token")
+        query_params["verbose"] = "true"
+        response = requests.request(
+            "GET",
+            self.api_url + "/update",
+            params=query_params,
+        )
+        # if the request fails for any reason, throw an error.
+        response.raise_for_status()
+        return response


Test patch:
diff --git a/lexicon/tests/providers/test_duckdns.py b/lexicon/tests/providers/test_duckdns.py
new file mode 100644
--- /dev/null
+++ b/lexicon/tests/providers/test_duckdns.py
@@ -0,0 +1,234 @@
+# Test for one implementation of the interface
+import pytest
+from lexicon.tests.providers import integration_tests
+from unittest import TestCase, mock
+
+from lexicon.providers.duckdns import Provider
+
+try:
+    import dns.resolver
+except ImportError:
+    pass
+
+
+# Hook into testing framework by inheriting unittest.TestCase and reuse
+# the tests which *each and every* implementation of the interface must
+# pass, by inheritance from integration_tests.IntegrationTests
+class DuckdnsProviderTests(TestCase, integration_tests.IntegrationTestsV2):
+    """Integration tests for Duck DNS"""
+    provider_name = "duckdns"
+    domain = "testlexicon.duckdns.org"
+
+    def _filter_headers(self):
+        return ["Location"]
+
+    def _filter_query_parameters(self):
+        return ["token"]
+
+    # skip unsupported features
+    @pytest.mark.skip(reason="The Duck DNS API does not support authentication")
+    def test_provider_authenticate(self):
+        return
+
+    @pytest.mark.skip(reason="The Duck DNS API does not support authentication")
+    def test_provider_authenticate_with_unmanaged_domain_should_fail(self):
+        return
+
+    @pytest.mark.skip(reason="The Duck DNS API does not support CNAME records")
+    def test_provider_when_calling_create_record_for_CNAME_with_valid_name_and_content(self):
+        return
+
+    @pytest.mark.skip(reason="The Duck DNS API does not support altering the TTL")
+    def test_provider_when_calling_list_records_after_setting_ttl(self):
+        return
+
+    @pytest.mark.skip(reason="The Duck DNS API does not support record sets")
+    def test_provider_when_calling_create_record_multiple_times_should_create_record_set(self):
+        return
+
+    @pytest.mark.skip(reason="The Duck DNS API does not support record sets")
+    def test_provider_when_calling_list_records_should_handle_record_sets(self):
+        return
+
+    @pytest.mark.skip(reason="The Duck DNS API does not support record sets")
+    def test_provider_when_calling_delete_record_with_record_set_name_remove_all(self):
+        return
+
+    @pytest.mark.skip(reason="The Duck DNS API does not support record sets")
+    def test_provider_when_calling_delete_record_with_record_set_by_content_should_leave_others_untouched(self):
+        return
+
+    @pytest.mark.skip(reason="The Duck DNS API does not support multiple TXT records")
+    def test_provider_when_calling_update_record_should_modify_record(self):
+        return
+
+    @pytest.mark.skip(reason="The Duck DNS API does not support multiple TXT records")
+    def test_provider_when_calling_update_record_with_full_name_should_modify_record(self):
+        return
+
+    @pytest.mark.skip(reason="The Duck DNS API does not support multiple TXT records")
+    def test_provider_when_calling_update_record_with_fqdn_name_should_modify_record(self):
+        return
+
+    # mock DNS queries
+    @mock.patch.object(Provider, "_get_dns_rrset")
+    def test_provider_when_calling_create_record_with_duplicate_records_should_be_noop(
+        self,
+        mock_rrset,
+    ):
+        mock_rrset.side_effect = [
+            # list records, no A no AAAA one TXT
+            dns.resolver.NoAnswer,
+            dns.resolver.NoAnswer,
+            dns.rrset.from_text("testlexicon.duckdns.org", 60, "IN", "TXT", "challengetoken"),
+        ]
+        super().test_provider_when_calling_create_record_with_duplicate_records_should_be_noop()
+
+    @mock.patch.object(Provider, "_get_dns_rrset")
+    def test_provider_when_calling_list_records_with_no_arguments_should_list_all(
+        self,
+        mock_rrset,
+    ):
+        mock_rrset.side_effect = [
+            # list records, no A no AAAA no TXT
+            dns.resolver.NoAnswer,
+            dns.resolver.NoAnswer,
+            dns.resolver.NoAnswer,
+        ]
+        super().test_provider_when_calling_list_records_with_no_arguments_should_list_all()
+
+    @mock.patch.object(Provider, "_get_dns_rrset")
+    def test_provider_when_calling_list_records_with_fqdn_name_filter_should_return_record(
+        self,
+        mock_rrset,
+    ):
+        mock_rrset.side_effect = [
+            # list records, no A no AAAA one TXT
+            dns.resolver.NoAnswer,
+            dns.resolver.NoAnswer,
+            dns.rrset.from_text("testlexicon.duckdns.org", 60, "IN", "TXT", "challengetoken"),
+        ]
+        super().test_provider_when_calling_list_records_with_fqdn_name_filter_should_return_record()
+
+    @mock.patch.object(Provider, "_get_dns_rrset")
+    def test_provider_when_calling_list_records_with_full_name_filter_should_return_record(
+        self,
+        mock_rrset,
+    ):
+        mock_rrset.side_effect = [
+            # list records, no A no AAAA one TXT
+            dns.resolver.NoAnswer,
+            dns.resolver.NoAnswer,
+            dns.rrset.from_text("testlexicon.duckdns.org", 60, "IN", "TXT", "challengetoken"),
+        ]
+        super().test_provider_when_calling_list_records_with_full_name_filter_should_return_record()
+
+    @mock.patch.object(Provider, "_get_dns_rrset")
+    def test_provider_when_calling_list_records_with_name_filter_should_return_record(
+        self,
+        mock_rrset,
+    ):
+        mock_rrset.side_effect = [
+            # list records, no A no AAAA one TXT
+            dns.resolver.NoAnswer,
+            dns.resolver.NoAnswer,
+            dns.rrset.from_text("testlexicon.duckdns.org", 60, "IN", "TXT", "challengetoken"),
+        ]
+        super().test_provider_when_calling_list_records_with_name_filter_should_return_record()
+
+    @mock.patch.object(Provider, "_get_dns_rrset")
+    def test_provider_when_calling_update_record_should_modify_record_name_specified(
+        self,
+        mock_rrset,
+    ):
+        mock_rrset.side_effect = [
+            # update record with a list, no A no AAAA one TXT
+            dns.resolver.NoAnswer,
+            dns.resolver.NoAnswer,
+            dns.rrset.from_text("testlexicon.duckdns.org", 60, "IN", "TXT", "challengetoken"),
+        ]
+        super().test_provider_when_calling_update_record_should_modify_record_name_specified()
+
+    @mock.patch.object(Provider, "_get_dns_rrset")
+    def test_provider_when_calling_delete_record_by_filter_should_remove_record(
+        self,
+        mock_rrset,
+    ):
+        mock_rrset.side_effect = [
+            # delete record with a list, no A no AAAA one TXT
+            dns.resolver.NoAnswer,
+            dns.resolver.NoAnswer,
+            dns.rrset.from_text("testlexicon.duckdns.org", 60, "IN", "TXT", "challengetoken"),
+            # list records after delete, no A no AAAA no TXT
+            dns.resolver.NoAnswer,
+            dns.resolver.NoAnswer,
+            dns.resolver.NoAnswer,
+        ]
+        super().test_provider_when_calling_delete_record_by_filter_should_remove_record()
+
+    @mock.patch.object(Provider, "_get_dns_rrset")
+    def test_provider_when_calling_delete_record_by_filter_with_fqdn_name_should_remove_record(
+        self,
+        mock_rrset,
+    ):
+        mock_rrset.side_effect = [
+            # delete record with a list, no A no AAAA one TXT
+            dns.resolver.NoAnswer,
+            dns.resolver.NoAnswer,
+            dns.rrset.from_text("testlexicon.duckdns.org", 60, "IN", "TXT", "challengetoken"),
+            # list records after delete, no A no AAAA no TXT
+            dns.resolver.NoAnswer,
+            dns.resolver.NoAnswer,
+            dns.resolver.NoAnswer,
+        ]
+        super().test_provider_when_calling_delete_record_by_filter_with_fqdn_name_should_remove_record()
+
+    @mock.patch.object(Provider, "_get_dns_rrset")
+    def test_provider_when_calling_delete_record_by_filter_with_full_name_should_remove_record(
+        self,
+        mock_rrset,
+    ):
+        mock_rrset.side_effect = [
+            # delete record with a list, no A no AAAA one TXT
+            dns.resolver.NoAnswer,
+            dns.resolver.NoAnswer,
+            dns.rrset.from_text("testlexicon.duckdns.org", 60, "IN", "TXT", "challengetoken"),
+            # list records after delete, no A no AAAA no TXT
+            dns.resolver.NoAnswer,
+            dns.resolver.NoAnswer,
+            dns.resolver.NoAnswer,
+        ]
+        super().test_provider_when_calling_delete_record_by_filter_with_full_name_should_remove_record()
+
+    @mock.patch.object(Provider, "_get_dns_rrset")
+    def test_provider_when_calling_delete_record_by_identifier_should_remove_record(
+        self,
+        mock_rrset,
+    ):
+        mock_rrset.side_effect = [
+            # first list, no A no AAAA one TXT
+            dns.resolver.NoAnswer,
+            dns.resolver.NoAnswer,
+            dns.rrset.from_text("testlexicon.duckdns.org", 60, "IN", "TXT", "challengetoken"),
+            # second list, no A no AAAA no TXT
+            dns.resolver.NoAnswer,
+            dns.resolver.NoAnswer,
+            dns.resolver.NoAnswer,
+        ]
+        super().test_provider_when_calling_delete_record_by_identifier_should_remove_record()
+
+    @mock.patch.object(Provider, "_get_dns_rrset")
+    def test_provider_when_calling_list_records_with_invalid_filter_should_be_empty_list(
+        self,
+        mock_rrset,
+    ):
+        mock_rrset.side_effect = dns.resolver.NoAnswer
+        super().test_provider_when_calling_list_records_with_invalid_filter_should_be_empty_list()
+
+    # provider specific tests
+    def test_duckdns_domain_logic(self):
+        assert Provider._get_duckdns_domain("testlexicon") == "testlexicon"
+        assert Provider._get_duckdns_domain("testlexicon.duckdns.org") == "testlexicon"
+        assert Provider._get_duckdns_domain("test.testlexicon.duckdns.org") == "testlexicon"
+        with pytest.raises(Exception):
+            Provider._get_duckdns_domain("test.testlexicon")
diff --git a/tests/fixtures/cassettes/duckdns/IntegrationTests/test_provider_when_calling_create_record_for_A_with_valid_name_and_content.yaml b/tests/fixtures/cassettes/duckdns/IntegrationTests/test_provider_when_calling_create_record_for_A_with_valid_name_and_content.yaml
new file mode 100644
--- /dev/null
+++ b/tests/fixtures/cassettes/duckdns/IntegrationTests/test_provider_when_calling_create_record_for_A_with_valid_name_and_content.yaml
@@ -0,0 +1,44 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      User-Agent:
+      - python-requests/2.28.1
+    method: GET
+    uri: https://www.duckdns.org/update?domains=testlexicon&ip=127.0.0.1&verbose=true
+  response:
+    body:
+      string: 'OK
+
+        127.0.0.1
+
+
+        UPDATED'
+    headers:
+      Connection:
+      - keep-alive
+      Date:
+      - Sat, 14 Jan 2023 09:40:26 GMT
+      Server:
+      - nginx/1.20.0
+      Set-Cookie:
+      - AWSALB=vnYC+A0J4nQfe19V1xCqIDYigluk0eD4I9p1XqMWMani95mKKuwRQ5LX9/7zVedqn2h6AsWJZG2mLpcVagMSbn5ZDkXwIUT+RBvPiwvkQH3LkcFzjPdi79RZuZvU;
+        Expires=Sat, 21 Jan 2023 09:40:26 GMT; Path=/
+      - AWSALBCORS=vnYC+A0J4nQfe19V1xCqIDYigluk0eD4I9p1XqMWMani95mKKuwRQ5LX9/7zVedqn2h6AsWJZG2mLpcVagMSbn5ZDkXwIUT+RBvPiwvkQH3LkcFzjPdi79RZuZvU;
+        Expires=Sat, 21 Jan 2023 09:40:26 GMT; Path=/; SameSite=None; Secure
+      Transfer-Encoding:
+      - chunked
+      X-Clacks-Overhead:
+      - GNU Terry Pratchett
+      X-Frame-Options:
+      - DENY
+    status:
+      code: 200
+      message: OK
+version: 1
diff --git a/tests/fixtures/cassettes/duckdns/IntegrationTests/test_provider_when_calling_create_record_for_TXT_with_fqdn_name_and_content.yaml b/tests/fixtures/cassettes/duckdns/IntegrationTests/test_provider_when_calling_create_record_for_TXT_with_fqdn_name_and_content.yaml
new file mode 100644
--- /dev/null
+++ b/tests/fixtures/cassettes/duckdns/IntegrationTests/test_provider_when_calling_create_record_for_TXT_with_fqdn_name_and_content.yaml
@@ -0,0 +1,43 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      User-Agent:
+      - python-requests/2.28.1
+    method: GET
+    uri: https://www.duckdns.org/update?domains=testlexicon&txt=challengetoken&verbose=true
+  response:
+    body:
+      string: 'OK
+
+        challengetoken
+
+        UPDATED'
+    headers:
+      Connection:
+      - keep-alive
+      Date:
+      - Sat, 14 Jan 2023 09:40:26 GMT
+      Server:
+      - nginx/1.20.0
+      Set-Cookie:
+      - AWSALB=FseKedCsSxPTrDLP83pfrP1/9OdtNFaEyhdaRT19o7KWrFyLcH0ss9LGKTa+EIIvyB5vn9LSHxC3DdzqLlpTw/mN1kbJOJk/WI+Xp/eZv1pmhrIRY2oPaRKoP8ot;
+        Expires=Sat, 21 Jan 2023 09:40:26 GMT; Path=/
+      - AWSALBCORS=FseKedCsSxPTrDLP83pfrP1/9OdtNFaEyhdaRT19o7KWrFyLcH0ss9LGKTa+EIIvyB5vn9LSHxC3DdzqLlpTw/mN1kbJOJk/WI+Xp/eZv1pmhrIRY2oPaRKoP8ot;
+        Expires=Sat, 21 Jan 2023 09:40:26 GMT; Path=/; SameSite=None; Secure
+      Transfer-Encoding:
+      - chunked
+      X-Clacks-Overhead:
+      - GNU Terry Pratchett
+      X-Frame-Options:
+      - DENY
+    status:
+      code: 200
+      message: OK
+version: 1
diff --git a/tests/fixtures/cassettes/duckdns/IntegrationTests/test_provider_when_calling_create_record_for_TXT_with_full_name_and_content.yaml b/tests/fixtures/cassettes/duckdns/IntegrationTests/test_provider_when_calling_create_record_for_TXT_with_full_name_and_content.yaml
new file mode 100644
--- /dev/null
+++ b/tests/fixtures/cassettes/duckdns/IntegrationTests/test_provider_when_calling_create_record_for_TXT_with_full_name_and_content.yaml
@@ -0,0 +1,43 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      User-Agent:
+      - python-requests/2.28.1
+    method: GET
+    uri: https://www.duckdns.org/update?domains=testlexicon&txt=challengetoken&verbose=true
+  response:
+    body:
+      string: 'OK
+
+        challengetoken
+
+        UPDATED'
+    headers:
+      Connection:
+      - keep-alive
+      Date:
+      - Sat, 14 Jan 2023 09:40:27 GMT
+      Server:
+      - nginx/1.20.0
+      Set-Cookie:
+      - AWSALB=Hh/bmfCSxg4vgmLfIEDx2W3QouP04UnFKsv2r4tcPyqtJ7iFrS94F1pdVuTSP+Ml6b0HPsqiH5KtRoa1skGw4GnJN9BaZ3o6u3TqqPThRIl7PyKWyHquEaz4RsC7;
+        Expires=Sat, 21 Jan 2023 09:40:27 GMT; Path=/
+      - AWSALBCORS=Hh/bmfCSxg4vgmLfIEDx2W3QouP04UnFKsv2r4tcPyqtJ7iFrS94F1pdVuTSP+Ml6b0HPsqiH5KtRoa1skGw4GnJN9BaZ3o6u3TqqPThRIl7PyKWyHquEaz4RsC7;
+        Expires=Sat, 21 Jan 2023 09:40:27 GMT; Path=/; SameSite=None; Secure
+      Transfer-Encoding:
+      - chunked
+      X-Clacks-Overhead:
+      - GNU Terry Pratchett
+      X-Frame-Options:
+      - DENY
+    status:
+      code: 200
+      message: OK
+version: 1
diff --git a/tests/fixtures/cassettes/duckdns/IntegrationTests/test_provider_when_calling_create_record_for_TXT_with_valid_name_and_content.yaml b/tests/fixtures/cassettes/duckdns/IntegrationTests/test_provider_when_calling_create_record_for_TXT_with_valid_name_and_content.yaml
new file mode 100644
--- /dev/null
+++ b/tests/fixtures/cassettes/duckdns/IntegrationTests/test_provider_when_calling_create_record_for_TXT_with_valid_name_and_content.yaml
@@ -0,0 +1,43 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      User-Agent:
+      - python-requests/2.28.1
+    method: GET
+    uri: https://www.duckdns.org/update?domains=testlexicon&txt=challengetoken&verbose=true
+  response:
+    body:
+      string: 'OK
+
+        challengetoken
+
+        UPDATED'
+    headers:
+      Connection:
+      - keep-alive
+      Date:
+      - Sat, 14 Jan 2023 09:40:29 GMT
+      Server:
+      - nginx/1.20.0
+      Set-Cookie:
+      - AWSALB=TJc3qLPgQG7FdtIx4moTnwcf43xfow3mLsMiMj8Dx/QAg0wDYueG45k+2xW3zld6aTtV0slqauM2yB6KXM4EFaL8xaqpCIAECDA2ex4h/y7jjae/7MMF8EWpe6TG;
+        Expires=Sat, 21 Jan 2023 09:40:28 GMT; Path=/
+      - AWSALBCORS=TJc3qLPgQG7FdtIx4moTnwcf43xfow3mLsMiMj8Dx/QAg0wDYueG45k+2xW3zld6aTtV0slqauM2yB6KXM4EFaL8xaqpCIAECDA2ex4h/y7jjae/7MMF8EWpe6TG;
+        Expires=Sat, 21 Jan 2023 09:40:28 GMT; Path=/; SameSite=None; Secure
+      Transfer-Encoding:
+      - chunked
+      X-Clacks-Overhead:
+      - GNU Terry Pratchett
+      X-Frame-Options:
+      - DENY
+    status:
+      code: 200
+      message: OK
+version: 1
diff --git a/tests/fixtures/cassettes/duckdns/IntegrationTests/test_provider_when_calling_create_record_with_duplicate_records_should_be_noop.yaml b/tests/fixtures/cassettes/duckdns/IntegrationTests/test_provider_when_calling_create_record_with_duplicate_records_should_be_noop.yaml
new file mode 100644
--- /dev/null
+++ b/tests/fixtures/cassettes/duckdns/IntegrationTests/test_provider_when_calling_create_record_with_duplicate_records_should_be_noop.yaml
@@ -0,0 +1,84 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      User-Agent:
+      - python-requests/2.28.1
+    method: GET
+    uri: https://www.duckdns.org/update?domains=testlexicon&txt=challengetoken&verbose=true
+  response:
+    body:
+      string: 'OK
+
+        challengetoken
+
+        UPDATED'
+    headers:
+      Connection:
+      - keep-alive
+      Date:
+      - Sat, 14 Jan 2023 09:40:29 GMT
+      Server:
+      - nginx/1.20.0
+      Set-Cookie:
+      - AWSALB=/K1VBcyFADVshh7ekxhcxKvA+RYfkfEjbHw3uyZeoaeA9d75VNoy3HEqcDSeBajbFoz6hj4+O1fULOV23g9E0vJFfNgqKKkJ/R2fEYiNQhG9zuv7l0SU6q2ZLw9X;
+        Expires=Sat, 21 Jan 2023 09:40:29 GMT; Path=/
+      - AWSALBCORS=/K1VBcyFADVshh7ekxhcxKvA+RYfkfEjbHw3uyZeoaeA9d75VNoy3HEqcDSeBajbFoz6hj4+O1fULOV23g9E0vJFfNgqKKkJ/R2fEYiNQhG9zuv7l0SU6q2ZLw9X;
+        Expires=Sat, 21 Jan 2023 09:40:29 GMT; Path=/; SameSite=None; Secure
+      Transfer-Encoding:
+      - chunked
+      X-Clacks-Overhead:
+      - GNU Terry Pratchett
+      X-Frame-Options:
+      - DENY
+    status:
+      code: 200
+      message: OK
+- request:
+    body: null
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      User-Agent:
+      - python-requests/2.28.1
+    method: GET
+    uri: https://www.duckdns.org/update?domains=testlexicon&txt=challengetoken&verbose=true
+  response:
+    body:
+      string: 'OK
+
+        challengetoken
+
+        NOCHANGE'
+    headers:
+      Connection:
+      - keep-alive
+      Date:
+      - Sat, 14 Jan 2023 09:40:30 GMT
+      Server:
+      - nginx/1.20.0
+      Set-Cookie:
+      - AWSALB=+OhHY48pQObX+o3UpSMzAoFnOB9d7swE9NwPPbYPsJoycZ8ozU2+BbAGKQs89InHLDzzA5C2ytdk6JGDTjfoYq+GtZZedOAmQQOS4tANP6tZGnHUR0HXCB+s9FGN;
+        Expires=Sat, 21 Jan 2023 09:40:30 GMT; Path=/
+      - AWSALBCORS=+OhHY48pQObX+o3UpSMzAoFnOB9d7swE9NwPPbYPsJoycZ8ozU2+BbAGKQs89InHLDzzA5C2ytdk6JGDTjfoYq+GtZZedOAmQQOS4tANP6tZGnHUR0HXCB+s9FGN;
+        Expires=Sat, 21 Jan 2023 09:40:30 GMT; Path=/; SameSite=None; Secure
+      Transfer-Encoding:
+      - chunked
+      X-Clacks-Overhead:
+      - GNU Terry Pratchett
+      X-Frame-Options:
+      - DENY
+    status:
+      code: 200
+      message: OK
+version: 1
diff --git a/tests/fixtures/cassettes/duckdns/IntegrationTests/test_provider_when_calling_delete_record_by_filter_should_remove_record.yaml b/tests/fixtures/cassettes/duckdns/IntegrationTests/test_provider_when_calling_delete_record_by_filter_should_remove_record.yaml
new file mode 100644
--- /dev/null
+++ b/tests/fixtures/cassettes/duckdns/IntegrationTests/test_provider_when_calling_delete_record_by_filter_should_remove_record.yaml
@@ -0,0 +1,83 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      User-Agent:
+      - python-requests/2.28.1
+    method: GET
+    uri: https://www.duckdns.org/update?domains=testlexicon&txt=challengetoken&verbose=true
+  response:
+    body:
+      string: 'OK
+
+        challengetoken
+
+        UPDATED'
+    headers:
+      Connection:
+      - keep-alive
+      Date:
+      - Sat, 14 Jan 2023 09:43:33 GMT
+      Server:
+      - nginx/1.20.0
+      Set-Cookie:
+      - AWSALB=5TJXAj5tud9BNSQL/zA/UhwL5G+EOGH74pviTAnZrxjUjYnJYUuxGYGbGyy+wkL9CIZZQhKrHTnlgPo6q3p8NTdXCfiTmvHMGgDs125quCamHBXhCy0pde0/Iesi;
+        Expires=Sat, 21 Jan 2023 09:43:33 GMT; Path=/
+      - AWSALBCORS=5TJXAj5tud9BNSQL/zA/UhwL5G+EOGH74pviTAnZrxjUjYnJYUuxGYGbGyy+wkL9CIZZQhKrHTnlgPo6q3p8NTdXCfiTmvHMGgDs125quCamHBXhCy0pde0/Iesi;
+        Expires=Sat, 21 Jan 2023 09:43:33 GMT; Path=/; SameSite=None; Secure
+      Transfer-Encoding:
+      - chunked
+      X-Clacks-Overhead:
+      - GNU Terry Pratchett
+      X-Frame-Options:
+      - DENY
+    status:
+      code: 200
+      message: OK
+- request:
+    body: null
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      User-Agent:
+      - python-requests/2.28.1
+    method: GET
+    uri: https://www.duckdns.org/update?domains=testlexicon&txt=&clear=true&verbose=true
+  response:
+    body:
+      string: 'OK
+
+
+        UPDATED'
+    headers:
+      Connection:
+      - keep-alive
+      Date:
+      - Sat, 14 Jan 2023 09:43:33 GMT
+      Server:
+      - nginx/1.20.0
+      Set-Cookie:
+      - AWSALB=IFv0U6tMR16tMDFLXGMRbMiYTGKlVA7C0mrGjwMcgSqGEzafcH0m84eCoNf7tcK23EnkVrUrT4ZXQJpWqdesZGs2EckBnxqPy8TpPrRhSoUnNtc9OCdTd2oKgwR6;
+        Expires=Sat, 21 Jan 2023 09:43:33 GMT; Path=/
+      - AWSALBCORS=IFv0U6tMR16tMDFLXGMRbMiYTGKlVA7C0mrGjwMcgSqGEzafcH0m84eCoNf7tcK23EnkVrUrT4ZXQJpWqdesZGs2EckBnxqPy8TpPrRhSoUnNtc9OCdTd2oKgwR6;
+        Expires=Sat, 21 Jan 2023 09:43:33 GMT; Path=/; SameSite=None; Secure
+      Transfer-Encoding:
+      - chunked
+      X-Clacks-Overhead:
+      - GNU Terry Pratchett
+      X-Frame-Options:
+      - DENY
+    status:
+      code: 200
+      message: OK
+version: 1
diff --git a/tests/fixtures/cassettes/duckdns/IntegrationTests/test_provider_when_calling_delete_record_by_filter_with_fqdn_name_should_remove_record.yaml b/tests/fixtures/cassettes/duckdns/IntegrationTests/test_provider_when_calling_delete_record_by_filter_with_fqdn_name_should_remove_record.yaml
new file mode 100644
--- /dev/null
+++ b/tests/fixtures/cassettes/duckdns/IntegrationTests/test_provider_when_calling_delete_record_by_filter_with_fqdn_name_should_remove_record.yaml
@@ -0,0 +1,83 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      User-Agent:
+      - python-requests/2.28.1
+    method: GET
+    uri: https://www.duckdns.org/update?domains=testlexicon&txt=challengetoken&verbose=true
+  response:
+    body:
+      string: 'OK
+
+        challengetoken
+
+        UPDATED'
+    headers:
+      Connection:
+      - keep-alive
+      Date:
+      - Sat, 14 Jan 2023 09:43:36 GMT
+      Server:
+      - nginx/1.20.0
+      Set-Cookie:
+      - AWSALB=k/Qc3yif/8lMsnjHct7Q9xkWzTA8PaDXJgc+0v5uchD/kJH/rTsa+T0u6JDcO3rBZspXj4q+E5rCCvZs7xH1xaLAQUTNoqpIc60QQ8G8KYDg8BBHsEvffnTt/POr;
+        Expires=Sat, 21 Jan 2023 09:43:34 GMT; Path=/
+      - AWSALBCORS=k/Qc3yif/8lMsnjHct7Q9xkWzTA8PaDXJgc+0v5uchD/kJH/rTsa+T0u6JDcO3rBZspXj4q+E5rCCvZs7xH1xaLAQUTNoqpIc60QQ8G8KYDg8BBHsEvffnTt/POr;
+        Expires=Sat, 21 Jan 2023 09:43:34 GMT; Path=/; SameSite=None; Secure
+      Transfer-Encoding:
+      - chunked
+      X-Clacks-Overhead:
+      - GNU Terry Pratchett
+      X-Frame-Options:
+      - DENY
+    status:
+      code: 200
+      message: OK
+- request:
+    body: null
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      User-Agent:
+      - python-requests/2.28.1
+    method: GET
+    uri: https://www.duckdns.org/update?domains=testlexicon&txt=&clear=true&verbose=true
+  response:
+    body:
+      string: 'OK
+
+
+        UPDATED'
+    headers:
+      Connection:
+      - keep-alive
+      Date:
+      - Sat, 14 Jan 2023 09:43:37 GMT
+      Server:
+      - nginx/1.20.0
+      Set-Cookie:
+      - AWSALB=OTZdEjU3+/7S7vQA+/VPmsgbfWo13rT7tr5vpqz8LZUAJo6kSSZgvBFvrgCtHsGXFfXwsF3P3JiUjoeljkQBxFeGQrBOcVVUYdPIxareXTeyt3DEPNahDUIzN+A2;
+        Expires=Sat, 21 Jan 2023 09:43:37 GMT; Path=/
+      - AWSALBCORS=OTZdEjU3+/7S7vQA+/VPmsgbfWo13rT7tr5vpqz8LZUAJo6kSSZgvBFvrgCtHsGXFfXwsF3P3JiUjoeljkQBxFeGQrBOcVVUYdPIxareXTeyt3DEPNahDUIzN+A2;
+        Expires=Sat, 21 Jan 2023 09:43:37 GMT; Path=/; SameSite=None; Secure
+      Transfer-Encoding:
+      - chunked
+      X-Clacks-Overhead:
+      - GNU Terry Pratchett
+      X-Frame-Options:
+      - DENY
+    status:
+      code: 200
+      message: OK
+version: 1
diff --git a/tests/fixtures/cassettes/duckdns/IntegrationTests/test_provider_when_calling_delete_record_by_filter_with_full_name_should_remove_record.yaml b/tests/fixtures/cassettes/duckdns/IntegrationTests/test_provider_when_calling_delete_record_by_filter_with_full_name_should_remove_record.yaml
new file mode 100644
--- /dev/null
+++ b/tests/fixtures/cassettes/duckdns/IntegrationTests/test_provider_when_calling_delete_record_by_filter_with_full_name_should_remove_record.yaml
@@ -0,0 +1,83 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      User-Agent:
+      - python-requests/2.28.1
+    method: GET
+    uri: https://www.duckdns.org/update?domains=testlexicon&txt=challengetoken&verbose=true
+  response:
+    body:
+      string: 'OK
+
+        challengetoken
+
+        UPDATED'
+    headers:
+      Connection:
+      - keep-alive
+      Date:
+      - Sat, 14 Jan 2023 09:43:40 GMT
+      Server:
+      - nginx/1.20.0
+      Set-Cookie:
+      - AWSALB=s4wshOGWWRjvpVppKnqKbgAMz2LycbuzvQXTXmMerXkpA1WXK0enE3UYk0AGZ3quMbLy9Vp99PJnpQbiFJtkr0rTX10v43OfGyGO9UmOFoT5x5l+rUF/jB6Cv1Qo;
+        Expires=Sat, 21 Jan 2023 09:43:38 GMT; Path=/
+      - AWSALBCORS=s4wshOGWWRjvpVppKnqKbgAMz2LycbuzvQXTXmMerXkpA1WXK0enE3UYk0AGZ3quMbLy9Vp99PJnpQbiFJtkr0rTX10v43OfGyGO9UmOFoT5x5l+rUF/jB6Cv1Qo;
+        Expires=Sat, 21 Jan 2023 09:43:38 GMT; Path=/; SameSite=None; Secure
+      Transfer-Encoding:
+      - chunked
+      X-Clacks-Overhead:
+      - GNU Terry Pratchett
+      X-Frame-Options:
+      - DENY
+    status:
+      code: 200
+      message: OK
+- request:
+    body: null
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      User-Agent:
+      - python-requests/2.28.1
+    method: GET
+    uri: https://www.duckdns.org/update?domains=testlexicon&txt=&clear=true&verbose=true
+  response:
+    body:
+      string: 'OK
+
+
+        UPDATED'
+    headers:
+      Connection:
+      - keep-alive
+      Date:
+      - Sat, 14 Jan 2023 09:43:41 GMT
+      Server:
+      - nginx/1.20.0
+      Set-Cookie:
+      - AWSALB=CNuZ/Kg0LmnNipKhnOK2GArAoUCiXaUtMEPBKRbCLgxCixeESCvVa1vE9QAJW2c3MvoN3dgqhk0iv0TKL1TCr0tquf2zKQG3juMrPSAERf6P9PRn7lTNhiz/pYMU;
+        Expires=Sat, 21 Jan 2023 09:43:41 GMT; Path=/
+      - AWSALBCORS=CNuZ/Kg0LmnNipKhnOK2GArAoUCiXaUtMEPBKRbCLgxCixeESCvVa1vE9QAJW2c3MvoN3dgqhk0iv0TKL1TCr0tquf2zKQG3juMrPSAERf6P9PRn7lTNhiz/pYMU;
+        Expires=Sat, 21 Jan 2023 09:43:41 GMT; Path=/; SameSite=None; Secure
+      Transfer-Encoding:
+      - chunked
+      X-Clacks-Overhead:
+      - GNU Terry Pratchett
+      X-Frame-Options:
+      - DENY
+    status:
+      code: 200
+      message: OK
+version: 1
diff --git a/tests/fixtures/cassettes/duckdns/IntegrationTests/test_provider_when_calling_delete_record_by_identifier_should_remove_record.yaml b/tests/fixtures/cassettes/duckdns/IntegrationTests/test_provider_when_calling_delete_record_by_identifier_should_remove_record.yaml
new file mode 100644
--- /dev/null
+++ b/tests/fixtures/cassettes/duckdns/IntegrationTests/test_provider_when_calling_delete_record_by_identifier_should_remove_record.yaml
@@ -0,0 +1,83 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      User-Agent:
+      - python-requests/2.28.1
+    method: GET
+    uri: https://www.duckdns.org/update?domains=testlexicon&txt=challengetoken&verbose=true
+  response:
+    body:
+      string: 'OK
+
+        challengetoken
+
+        UPDATED'
+    headers:
+      Connection:
+      - keep-alive
+      Date:
+      - Sat, 14 Jan 2023 09:43:43 GMT
+      Server:
+      - nginx/1.20.0
+      Set-Cookie:
+      - AWSALB=YEdpBtA0En5PaPdJsro8+1GmUZG7LZgSaEWipq9gWBUmWL5mKoqem8fmmjfXB4tdBZ8A/BUsKaTiIs9EA9WIvmjWMh31ogRbDytTuQ7Sxu5J5quSAl1JDrQrOW2X;
+        Expires=Sat, 21 Jan 2023 09:43:41 GMT; Path=/
+      - AWSALBCORS=YEdpBtA0En5PaPdJsro8+1GmUZG7LZgSaEWipq9gWBUmWL5mKoqem8fmmjfXB4tdBZ8A/BUsKaTiIs9EA9WIvmjWMh31ogRbDytTuQ7Sxu5J5quSAl1JDrQrOW2X;
+        Expires=Sat, 21 Jan 2023 09:43:41 GMT; Path=/; SameSite=None; Secure
+      Transfer-Encoding:
+      - chunked
+      X-Clacks-Overhead:
+      - GNU Terry Pratchett
+      X-Frame-Options:
+      - DENY
+    status:
+      code: 200
+      message: OK
+- request:
+    body: null
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      User-Agent:
+      - python-requests/2.28.1
+    method: GET
+    uri: https://www.duckdns.org/update?domains=testlexicon&txt=&clear=true&verbose=true
+  response:
+    body:
+      string: 'OK
+
+
+        UPDATED'
+    headers:
+      Connection:
+      - keep-alive
+      Date:
+      - Sat, 14 Jan 2023 09:43:44 GMT
+      Server:
+      - nginx/1.20.0
+      Set-Cookie:
+      - AWSALB=YHgbf6jjMXtHfnuDLrlm82c5bx28zGHX7pIROaZTQRCXN8N5AkeMR80GKe91EkOrjoRvb2fgHrsvT+9TuAD3of9I7vA3yD1oXIKapNvtj3vSGF5HZALmqdWJZMiI;
+        Expires=Sat, 21 Jan 2023 09:43:44 GMT; Path=/
+      - AWSALBCORS=YHgbf6jjMXtHfnuDLrlm82c5bx28zGHX7pIROaZTQRCXN8N5AkeMR80GKe91EkOrjoRvb2fgHrsvT+9TuAD3of9I7vA3yD1oXIKapNvtj3vSGF5HZALmqdWJZMiI;
+        Expires=Sat, 21 Jan 2023 09:43:44 GMT; Path=/; SameSite=None; Secure
+      Transfer-Encoding:
+      - chunked
+      X-Clacks-Overhead:
+      - GNU Terry Pratchett
+      X-Frame-Options:
+      - DENY
+    status:
+      code: 200
+      message: OK
+version: 1
diff --git a/tests/fixtures/cassettes/duckdns/IntegrationTests/test_provider_when_calling_list_records_with_fqdn_name_filter_should_return_record.yaml b/tests/fixtures/cassettes/duckdns/IntegrationTests/test_provider_when_calling_list_records_with_fqdn_name_filter_should_return_record.yaml
new file mode 100644
--- /dev/null
+++ b/tests/fixtures/cassettes/duckdns/IntegrationTests/test_provider_when_calling_list_records_with_fqdn_name_filter_should_return_record.yaml
@@ -0,0 +1,43 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      User-Agent:
+      - python-requests/2.28.1
+    method: GET
+    uri: https://www.duckdns.org/update?domains=testlexicon&txt=challengetoken&verbose=true
+  response:
+    body:
+      string: 'OK
+
+        challengetoken
+
+        UPDATED'
+    headers:
+      Connection:
+      - keep-alive
+      Date:
+      - Sat, 14 Jan 2023 09:50:10 GMT
+      Server:
+      - nginx/1.20.0
+      Set-Cookie:
+      - AWSALB=as14bPYSYlpDNofaSU4cxWOXeOxXi+/6V/KpNGbileih1ioRbydBgFccmgbBLvG3OwPtN7ogTBDfPTnTqFoZHLFE8qnXU7z5Had5M5S5Fz1crqnuethzzC9lJxtQ;
+        Expires=Sat, 21 Jan 2023 09:50:10 GMT; Path=/
+      - AWSALBCORS=as14bPYSYlpDNofaSU4cxWOXeOxXi+/6V/KpNGbileih1ioRbydBgFccmgbBLvG3OwPtN7ogTBDfPTnTqFoZHLFE8qnXU7z5Had5M5S5Fz1crqnuethzzC9lJxtQ;
+        Expires=Sat, 21 Jan 2023 09:50:10 GMT; Path=/; SameSite=None; Secure
+      Transfer-Encoding:
+      - chunked
+      X-Clacks-Overhead:
+      - GNU Terry Pratchett
+      X-Frame-Options:
+      - DENY
+    status:
+      code: 200
+      message: OK
+version: 1
diff --git a/tests/fixtures/cassettes/duckdns/IntegrationTests/test_provider_when_calling_list_records_with_full_name_filter_should_return_record.yaml b/tests/fixtures/cassettes/duckdns/IntegrationTests/test_provider_when_calling_list_records_with_full_name_filter_should_return_record.yaml
new file mode 100644
--- /dev/null
+++ b/tests/fixtures/cassettes/duckdns/IntegrationTests/test_provider_when_calling_list_records_with_full_name_filter_should_return_record.yaml
@@ -0,0 +1,43 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      User-Agent:
+      - python-requests/2.28.1
+    method: GET
+    uri: https://www.duckdns.org/update?domains=testlexicon&txt=challengetoken&verbose=true
+  response:
+    body:
+      string: 'OK
+
+        challengetoken
+
+        UPDATED'
+    headers:
+      Connection:
+      - keep-alive
+      Date:
+      - Sat, 14 Jan 2023 09:50:13 GMT
+      Server:
+      - nginx/1.20.0
+      Set-Cookie:
+      - AWSALB=j3c0DlsFGBEu+e6+IHoadH7V3gUIJiRydlc0GCGEgEJd16PO1nsnq9CkyuXJ7ZdeBd4XAi4YsSLIfLKtJceVDEIEUO3girOIkze1XhhXcW4j7Rmx6dtmWowQfnEJ;
+        Expires=Sat, 21 Jan 2023 09:50:10 GMT; Path=/
+      - AWSALBCORS=j3c0DlsFGBEu+e6+IHoadH7V3gUIJiRydlc0GCGEgEJd16PO1nsnq9CkyuXJ7ZdeBd4XAi4YsSLIfLKtJceVDEIEUO3girOIkze1XhhXcW4j7Rmx6dtmWowQfnEJ;
+        Expires=Sat, 21 Jan 2023 09:50:10 GMT; Path=/; SameSite=None; Secure
+      Transfer-Encoding:
+      - chunked
+      X-Clacks-Overhead:
+      - GNU Terry Pratchett
+      X-Frame-Options:
+      - DENY
+    status:
+      code: 200
+      message: OK
+version: 1
diff --git a/tests/fixtures/cassettes/duckdns/IntegrationTests/test_provider_when_calling_list_records_with_name_filter_should_return_record.yaml b/tests/fixtures/cassettes/duckdns/IntegrationTests/test_provider_when_calling_list_records_with_name_filter_should_return_record.yaml
new file mode 100644
--- /dev/null
+++ b/tests/fixtures/cassettes/duckdns/IntegrationTests/test_provider_when_calling_list_records_with_name_filter_should_return_record.yaml
@@ -0,0 +1,43 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      User-Agent:
+      - python-requests/2.28.1
+    method: GET
+    uri: https://www.duckdns.org/update?domains=testlexicon&txt=challengetoken&verbose=true
+  response:
+    body:
+      string: 'OK
+
+        challengetoken
+
+        UPDATED'
+    headers:
+      Connection:
+      - keep-alive
+      Date:
+      - Sat, 14 Jan 2023 09:50:13 GMT
+      Server:
+      - nginx/1.20.0
+      Set-Cookie:
+      - AWSALB=Ltx/35S97sz11ldlq43q6liYuIp1LDNIX2q8zR4Wuxxh/LbHLiigAZDuB0Iw1Hu7NFPhQ8H2Ci+I6OATIRIrJTcI61HmL1/+hRqALdQrRUQJcveL+Ntm+Fgbq7jC;
+        Expires=Sat, 21 Jan 2023 09:50:13 GMT; Path=/
+      - AWSALBCORS=Ltx/35S97sz11ldlq43q6liYuIp1LDNIX2q8zR4Wuxxh/LbHLiigAZDuB0Iw1Hu7NFPhQ8H2Ci+I6OATIRIrJTcI61HmL1/+hRqALdQrRUQJcveL+Ntm+Fgbq7jC;
+        Expires=Sat, 21 Jan 2023 09:50:13 GMT; Path=/; SameSite=None; Secure
+      Transfer-Encoding:
+      - chunked
+      X-Clacks-Overhead:
+      - GNU Terry Pratchett
+      X-Frame-Options:
+      - DENY
+    status:
+      code: 200
+      message: OK
+version: 1
diff --git a/tests/fixtures/cassettes/duckdns/IntegrationTests/test_provider_when_calling_update_record_should_modify_record_name_specified.yaml b/tests/fixtures/cassettes/duckdns/IntegrationTests/test_provider_when_calling_update_record_should_modify_record_name_specified.yaml
new file mode 100644
--- /dev/null
+++ b/tests/fixtures/cassettes/duckdns/IntegrationTests/test_provider_when_calling_update_record_should_modify_record_name_specified.yaml
@@ -0,0 +1,84 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      User-Agent:
+      - python-requests/2.28.1
+    method: GET
+    uri: https://www.duckdns.org/update?domains=testlexicon&txt=challengetoken&verbose=true
+  response:
+    body:
+      string: 'OK
+
+        challengetoken
+
+        UPDATED'
+    headers:
+      Connection:
+      - keep-alive
+      Date:
+      - Sat, 14 Jan 2023 09:53:10 GMT
+      Server:
+      - nginx/1.20.0
+      Set-Cookie:
+      - AWSALB=Ca8e4/4NDHQK4eewmZY/dPdBvMKWJtAsvPenCeS06wK0hoPdldXyat8iSz5FBBIaX4QaBD475YcsseZCqGN0XnKf4kgDj8brzufv4q2Yi/0/Tgmo0JjpDpaLAfCy;
+        Expires=Sat, 21 Jan 2023 09:53:10 GMT; Path=/
+      - AWSALBCORS=Ca8e4/4NDHQK4eewmZY/dPdBvMKWJtAsvPenCeS06wK0hoPdldXyat8iSz5FBBIaX4QaBD475YcsseZCqGN0XnKf4kgDj8brzufv4q2Yi/0/Tgmo0JjpDpaLAfCy;
+        Expires=Sat, 21 Jan 2023 09:53:10 GMT; Path=/; SameSite=None; Secure
+      Transfer-Encoding:
+      - chunked
+      X-Clacks-Overhead:
+      - GNU Terry Pratchett
+      X-Frame-Options:
+      - DENY
+    status:
+      code: 200
+      message: OK
+- request:
+    body: null
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      User-Agent:
+      - python-requests/2.28.1
+    method: GET
+    uri: https://www.duckdns.org/update?domains=testlexicon&txt=updated&verbose=true
+  response:
+    body:
+      string: 'OK
+
+        updated
+
+        UPDATED'
+    headers:
+      Connection:
+      - keep-alive
+      Date:
+      - Sat, 14 Jan 2023 09:53:12 GMT
+      Server:
+      - nginx/1.20.0
+      Set-Cookie:
+      - AWSALB=P1vu3JLGSD/EMzENTiGGi2nHvtk9IWjtdjUkdSii8KObSn8CoVlZrJxFN+jGas5tam4DD3k0QK5vAKSgl1rOvKGTxKNf890P6XgGt0U1LX+5Ql/tdDaFS607m9f+;
+        Expires=Sat, 21 Jan 2023 09:53:10 GMT; Path=/
+      - AWSALBCORS=P1vu3JLGSD/EMzENTiGGi2nHvtk9IWjtdjUkdSii8KObSn8CoVlZrJxFN+jGas5tam4DD3k0QK5vAKSgl1rOvKGTxKNf890P6XgGt0U1LX+5Ql/tdDaFS607m9f+;
+        Expires=Sat, 21 Jan 2023 09:53:10 GMT; Path=/; SameSite=None; Secure
+      Transfer-Encoding:
+      - chunked
+      X-Clacks-Overhead:
+      - GNU Terry Pratchett
+      X-Frame-Options:
+      - DENY
+    status:
+      code: 200
+      message: OK
+version: 1