From 52e167c93000c231979692f5ae28ec71feda2f50 Mon Sep 17 00:00:00 2001 From: Ludovic Fernandez Date: Sat, 12 Jul 2025 13:57:15 +0200 Subject: [PATCH] test: server client mock (#2571) --- go.mod | 1 + platform/tester/servermock/builder.go | 72 ++++ platform/tester/servermock/handler_dump.go | 20 + platform/tester/servermock/handler_file.go | 77 ++++ platform/tester/servermock/handler_json.go | 39 ++ platform/tester/servermock/handler_noop.go | 45 ++ platform/tester/servermock/handler_raw.go | 61 +++ platform/tester/servermock/link_form.go | 94 +++++ platform/tester/servermock/link_headers.go | 177 ++++++++ platform/tester/servermock/link_query.go | 97 +++++ .../tester/servermock/link_request_body.go | 89 ++++ .../servermock/link_request_body_json.go | 99 +++++ providers/dns/acmedns/acmedns_test.go | 42 +- .../internal/fixtures/request-body.json | 7 + .../dns/acmedns/internal/http_storage_test.go | 86 ++-- providers/dns/allinkl/internal/client_test.go | 74 ++-- .../fixtures/add_dns_settings-request.xml | 7 + .../fixtures/delete_dns_settings-request.xml | 7 + .../fixtures/get_dns_settings-request.xml | 7 + .../dns/allinkl/internal/identity_test.go | 31 +- .../dns/arvancloud/internal/client_test.go | 108 ++--- .../fixtures/create_record-request.json | 8 + providers/dns/auroradns/auroradns_test.go | 128 +++--- providers/dns/autodns/internal/client_test.go | 77 ++-- .../internal/fixtures/add_record-request.json | 11 + .../{add-record.json => add_record.json} | 0 .../fixtures/remove_record-request.json | 11 + ...{remove-record.json => remove_record.json} | 0 .../dns/axelname/internal/client_test.go | 87 ++-- providers/dns/azion/azion_test.go | 65 ++- providers/dns/bluecat/internal/client_test.go | 40 +- .../dns/bluecat/internal/identity_test.go | 47 +-- .../dns/bookmyname/internal/client_test.go | 92 +++-- providers/dns/brandit/internal/client_test.go | 111 +++-- .../dns/checkdomain/internal/client_test.go | 273 +++---------- .../fixtures/create_record-request.json | 7 + .../fixtures/delete_txt_record-request.json | 16 + .../dns/clouddns/internal/client_test.go | 144 ++----- .../internal/fixtures/domain-request.json | 14 + .../fixtures/domain_search-request.json | 14 + .../internal/fixtures/domain_search.json | 8 + .../internal/fixtures/login-request.json | 4 + .../dns/clouddns/internal/fixtures/login.json | 5 + .../internal/fixtures/publish-request.json | 3 + .../internal/fixtures/record_txt-request.json | 6 + .../dns/clouddns/internal/identity_test.go | 32 +- providers/dns/cloudns/internal/client_test.go | 201 +++++---- providers/dns/cloudru/internal/client_test.go | 80 ++-- .../dns/cloudru/internal/identity_test.go | 90 ++-- providers/dns/conoha/internal/client_test.go | 123 ++---- .../dns/conoha/internal/fixtures/empty.json | 1 + .../dns/conoha/internal/identity_test.go | 22 +- .../dns/conohav3/internal/client_test.go | 124 ++---- .../dns/conohav3/internal/fixtures/empty.json | 1 + .../dns/conohav3/internal/identity_test.go | 28 +- .../dns/constellix/internal/domains_test.go | 74 +--- .../constellix/internal/txtrecords_test.go | 136 +----- .../dns/corenetworks/internal/client_test.go | 163 ++------ .../corenetworks/internal/identity_test.go | 24 ++ .../dns/cpanel/internal/cpanel/client_test.go | 108 ++--- .../dns/cpanel/internal/whm/client_test.go | 109 ++--- providers/dns/derak/internal/client_test.go | 159 +++----- .../dns/digitalocean/digitalocean_test.go | 83 ++-- .../dns/digitalocean/internal/client_test.go | 116 +----- .../dns/directadmin/internal/client_test.go | 133 +++--- .../dns/dnshomede/internal/client_test.go | 109 +++-- providers/dns/dnshomede/internal/readme.md | 4 +- providers/dns/dnsmadeeasy/internal/client.go | 5 +- .../dns/dnsmadeeasy/internal/client_test.go | 120 +++++- .../fixtures/create_record-request.json | 8 + .../internal/fixtures/get_records.json | 20 + providers/dns/dode/internal/client_test.go | 82 +--- .../dns/domeneshop/internal/client_test.go | 160 ++------ .../fixtures/create_record-request.json | 7 + .../internal/fixtures/create_record.json | 3 + .../internal/fixtures/delete_record.json | 9 + .../internal/fixtures/getDnsRecords.json | 9 + .../internal/fixtures/getDomains.json | 22 + providers/dns/dreamhost/dreamhost_test.go | 105 ++--- .../dns/dreamhost/internal/client_test.go | 46 ++- providers/dns/duckdns/internal/client.go | 4 +- providers/dns/duckdns/internal/client_test.go | 39 ++ providers/dns/dyn/internal/client_test.go | 108 ++--- providers/dns/dyn/internal/session_test.go | 19 +- .../dns/dyndnsfree/internal/client_test.go | 50 +-- providers/dns/dynu/internal/client_test.go | 102 ++--- .../fixtures/add_new_record-request.json | 9 + providers/dns/easydns/easydns_test.go | 377 +++++++---------- providers/dns/easydns/internal/client_test.go | 88 ++-- .../dns/efficientip/internal/client_test.go | 114 ++---- providers/dns/epik/internal/client_test.go | 117 +++--- providers/dns/f5xc/internal/client_test.go | 98 ++--- providers/dns/gandi/gandi_test.go | 48 ++- providers/dns/gandi/internal/client_test.go | 99 +++++ .../fixtures/add_txt_record-request.xml | 49 +++ .../internal/fixtures/clone_zone-request.xml | 31 ++ .../gandi/internal/fixtures/clone_zone.xml | 22 + .../internal/fixtures/delete_zone-request.xml | 14 + .../gandi/internal/fixtures/delete_zone.xml | 9 + .../dns/gandi/internal/fixtures/empty.xml | 2 + .../internal/fixtures/get_zone_id-request.xml | 14 + .../gandi/internal/fixtures/get_zone_id.xml | 22 + .../fixtures/new_zone_version-request.xml | 14 + .../internal/fixtures/new_zone_version.xml | 9 + .../internal/fixtures/set_zone-request.xml | 19 + .../dns/gandi/internal/fixtures/set_zone.xml | 22 + .../fixtures/set_zone_version-request.xml | 19 + .../internal/fixtures/set_zone_version.xml | 9 + providers/dns/gandiv5/gandiv5_test.go | 91 +---- providers/dns/gandiv5/internal/client_test.go | 48 +++ .../internal/fixtures/add_txt_record_get.json | 8 + .../internal/fixtures/api_response.json | 4 + providers/dns/gcloud/googlecloud_test.go | 361 +++++++--------- providers/dns/gcore/internal/client_test.go | 272 +++++------- providers/dns/glesys/internal/client_test.go | 73 +--- providers/dns/godaddy/internal/client_test.go | 115 ++---- .../fixtures/update_records-request.json | 38 ++ providers/dns/hetzner/internal/client_test.go | 160 ++------ .../fixtures/create_txt_record-request.json | 7 + .../dns/hosttech/internal/client_test.go | 136 +++--- providers/dns/httpreq/httpreq_test.go | 202 ++++----- .../dns/hurricane/internal/client_test.go | 44 +- .../dns/hyperone/internal/client_test.go | 146 +++---- .../dns/infomaniak/internal/client_test.go | 141 ++----- .../fixtures/create_dns_record-request.json | 6 + .../internal/fixtures/get_domain_name.json | 13 + .../dns/internal/active24/client_test.go | 99 +++-- .../dns/internal/hostingde/client_test.go | 104 +---- .../fixtures/zoneConfigsFind-request.json | 9 + .../fixtures/zoneUpdate-request.json | 35 ++ .../dns/internal/rimuhosting/client_test.go | 386 +++++++++--------- .../dns/internal/selectel/client_test.go | 156 ++----- .../selectel/fixtures/add_record-request.json | 7 + .../selectel/fixtures/add_record.json | 8 + .../dns/internetbs/internal/client_test.go | 114 +++--- .../internal/fixtures/auth_error.json | 6 + providers/dns/ionos/internal/client.go | 5 +- providers/dns/ionos/internal/client_test.go | 125 +++--- providers/dns/ipv64/internal/client_test.go | 90 ++-- .../dns/iwantmyname/internal/client_test.go | 72 +--- .../dns/joker/internal/dmapi/client_test.go | 51 ++- .../dns/joker/internal/dmapi/identity_test.go | 127 +++--- .../dns/joker/internal/svc/client_test.go | 91 ++--- providers/dns/liara/internal/client_test.go | 112 ++--- providers/dns/lightsail/lightsail_test.go | 37 +- providers/dns/lightsail/mock_server_test.go | 44 -- .../dns/limacity/internal/client_test.go | 132 +++--- providers/dns/linode/linode_test.go | 322 +++++++-------- providers/dns/liquidweb/liquidweb_test.go | 22 +- providers/dns/liquidweb/servermock_test.go | 140 +++---- providers/dns/loopia/internal/client_test.go | 187 ++++----- providers/dns/luadns/internal/client_test.go | 120 ++---- .../dns/manageengine/internal/client_test.go | 139 ++++--- .../dns/metaregistrar/internal/client.go | 4 +- .../dns/metaregistrar/internal/client_test.go | 63 ++- .../dns/mijnhost/internal/client_test.go | 85 ++-- .../dns/mittwald/internal/client_test.go | 106 +++-- providers/dns/myaddr/internal/client_test.go | 72 ++-- providers/dns/mydnsjp/internal/client_test.go | 93 ++--- .../dns/mythicbeasts/internal/client_test.go | 73 ++-- .../mythicbeasts/internal/fixtures/token.json | 5 + .../mythicbeasts/internal/identity_test.go | 70 ++-- .../dns/namecheap/internal/client_test.go | 165 +++----- providers/dns/namecheap/namecheap_test.go | 181 ++++---- .../nearlyfreespeech/internal/client_test.go | 127 ++---- .../dns/netcup/internal/client_live_test.go | 137 +++++++ providers/dns/netcup/internal/client_test.go | 261 ++---------- .../fixtures/get_dns_records-request.json | 9 + .../internal/fixtures/get_dns_records.json | 32 ++ .../fixtures/get_dns_records_error.json | 10 + .../get_dns_records_error_unmarshal.json | 10 + .../internal/fixtures/login-request.json | 8 + .../dns/netcup/internal/fixtures/login.json | 12 + .../netcup/internal/fixtures/login_error.json | 10 + .../fixtures/login_error_unmarshal.json | 10 + .../internal/fixtures/logout-request.json | 8 + .../dns/netcup/internal/fixtures/logout.json | 10 + .../internal/fixtures/logout_error.json | 10 + providers/dns/netcup/internal/session_test.go | 207 ++-------- providers/dns/netlify/internal/client_test.go | 120 ++---- .../dns/nicmanager/internal/client_test.go | 127 +++--- providers/dns/nicru/internal/client_test.go | 133 +++--- .../dns/nifcloud/internal/client_test.go | 62 +-- providers/dns/njalla/internal/client_test.go | 181 +++----- .../internal/fixtures/add_record-request.json | 10 + .../njalla/internal/fixtures/add_record.json | 12 + .../njalla/internal/fixtures/auth_error.json | 7 + .../fixtures/list_records-request.json | 6 + .../internal/fixtures/list_records.json | 24 ++ .../fixtures/remove_record-request.json | 7 + .../remove_record_error_missing_domain.json | 7 + .../remove_record_error_missing_id.json | 7 + providers/dns/otc/internal/client_test.go | 109 +++++ providers/dns/otc/internal/identity_test.go | 22 +- providers/dns/otc/internal/mock.go | 140 +------ providers/dns/otc/otc_test.go | 359 +++++++++++----- providers/dns/pdns/internal/client.go | 5 +- providers/dns/pdns/internal/client_test.go | 118 +++--- .../pdns/internal/fixtures/zone-request.json | 19 + providers/dns/plesk/internal/client_test.go | 98 ++--- providers/dns/rackspace/fixtures/delete.json | 7 + .../dns/rackspace/fixtures/identity.json | 31 ++ providers/dns/rackspace/fixtures/record.json | 8 + .../rackspace/fixtures/record_details.json | 13 + .../dns/rackspace/fixtures/zone_details.json | 12 + providers/dns/rackspace/internal/client.go | 6 +- .../dns/rackspace/internal/client_test.go | 86 ++-- .../dns/rackspace/internal/identity_test.go | 38 +- .../dns/rackspace/rackspace_mock_test.go | 87 ---- providers/dns/rackspace/rackspace_test.go | 194 ++++----- providers/dns/rainyun/internal/client_test.go | 97 +++-- .../dns/rcodezero/internal/client_test.go | 59 +-- providers/dns/regru/internal/client_test.go | 96 ++--- .../internal/fixtures/add_txt_record.json | 14 + .../fixtures/add_txt_record_error_auth.json | 10 + .../fixtures/add_txt_record_error_domain.json | 14 + .../internal/fixtures/remove_record.json | 14 + .../fixtures/remove_record_error_auth.json | 10 + .../fixtures/remove_record_error_domain.json | 14 + providers/dns/regru/internal/readme.md | 6 + .../changeResourceRecordSetsResponse.xml | 8 + .../route53/fixtures/getChangeResponse.xml | 8 + .../listHostedZonesByNameResponse.xml | 19 + providers/dns/route53/fixtures_test.go | 39 -- providers/dns/route53/mock_test.go | 51 --- providers/dns/route53/route53_test.go | 65 +-- providers/dns/safedns/internal/client_test.go | 119 +++--- .../internal/fixtures/add_record-request.json | 6 + .../safedns/internal/fixtures/add_record.json | 8 + .../dns/safedns/internal/fixtures/error.json | 3 + providers/dns/selectelv2/selectelv2.go | 20 +- .../dns/selfhostde/internal/client_test.go | 57 +-- .../dns/servercow/internal/client_test.go | 167 ++------ .../dns/shellrent/internal/client_test.go | 129 +++--- providers/dns/simply/internal/client_test.go | 126 +++--- providers/dns/sonic/internal/client_test.go | 28 +- .../dns/spaceship/internal/client_test.go | 86 ++-- .../dns/stackpath/internal/client_test.go | 111 ++--- .../internal/fixtures/get_zone_records.json | 6 + .../internal/fixtures/get_zones.json | 30 ++ .../dns/technitium/internal/client_test.go | 73 ++-- .../dns/timewebcloud/internal/client_test.go | 127 ++---- .../dns/variomedia/internal/client_test.go | 82 ++-- .../dns/vegadns/fixtures/create_record.json | 12 + .../dns/vegadns/fixtures/record_delete.json | 3 + providers/dns/vegadns/fixtures/records.json | 43 ++ providers/dns/vegadns/fixtures/token.json | 5 + providers/dns/vegadns/vegadns_mock_test.go | 85 ---- providers/dns/vegadns/vegadns_test.go | 261 ++++-------- providers/dns/vercel/internal/client_test.go | 95 ++--- .../fixtures/error_failToCreateTXT.json | 6 + .../versio/fixtures/error_failToFindZone.json | 6 + providers/dns/versio/fixtures/token.json | 5 + providers/dns/versio/internal/client_test.go | 108 ++--- .../fixtures/update-domain-request.json | 78 ++++ providers/dns/versio/versio_mock_test.go | 13 - providers/dns/versio/versio_test.go | 169 +++----- providers/dns/vinyldns/mock_test.go | 114 ------ providers/dns/vinyldns/vinyldns_test.go | 81 ++-- providers/dns/vkcloud/vkcloud.go | 30 +- providers/dns/vultr/vultr_test.go | 77 ++-- .../dns/webnames/internal/client_test.go | 109 ++--- providers/dns/wedos/internal/client_test.go | 111 ++--- providers/dns/westcn/internal/client_test.go | 166 +++----- providers/dns/yandex/internal/client_test.go | 380 +++++------------ .../yandex/internal/fixtures/add_record.json | 13 + .../internal/fixtures/add_record_error.json | 5 + .../yandex/internal/fixtures/get_records.json | 24 ++ .../internal/fixtures/get_records_error.json | 5 + .../internal/fixtures/remove_record.json | 5 + .../fixtures/remove_record_error.json | 6 + .../dns/yandex360/internal/client_test.go | 75 ++-- providers/dns/yandexcloud/yandexcloud.go | 38 +- providers/dns/zoneee/internal/client_test.go | 71 ++-- providers/dns/zoneee/zoneee_test.go | 258 +++++------- 275 files changed, 8813 insertions(+), 10238 deletions(-) create mode 100644 platform/tester/servermock/builder.go create mode 100644 platform/tester/servermock/handler_dump.go create mode 100644 platform/tester/servermock/handler_file.go create mode 100644 platform/tester/servermock/handler_json.go create mode 100644 platform/tester/servermock/handler_noop.go create mode 100644 platform/tester/servermock/handler_raw.go create mode 100644 platform/tester/servermock/link_form.go create mode 100644 platform/tester/servermock/link_headers.go create mode 100644 platform/tester/servermock/link_query.go create mode 100644 platform/tester/servermock/link_request_body.go create mode 100644 platform/tester/servermock/link_request_body_json.go create mode 100644 providers/dns/acmedns/internal/fixtures/request-body.json create mode 100644 providers/dns/allinkl/internal/fixtures/add_dns_settings-request.xml create mode 100644 providers/dns/allinkl/internal/fixtures/delete_dns_settings-request.xml create mode 100644 providers/dns/allinkl/internal/fixtures/get_dns_settings-request.xml create mode 100644 providers/dns/arvancloud/internal/fixtures/create_record-request.json create mode 100644 providers/dns/autodns/internal/fixtures/add_record-request.json rename providers/dns/autodns/internal/fixtures/{add-record.json => add_record.json} (100%) create mode 100644 providers/dns/autodns/internal/fixtures/remove_record-request.json rename providers/dns/autodns/internal/fixtures/{remove-record.json => remove_record.json} (100%) create mode 100644 providers/dns/checkdomain/internal/fixtures/create_record-request.json create mode 100644 providers/dns/checkdomain/internal/fixtures/delete_txt_record-request.json create mode 100644 providers/dns/clouddns/internal/fixtures/domain-request.json create mode 100644 providers/dns/clouddns/internal/fixtures/domain_search-request.json create mode 100644 providers/dns/clouddns/internal/fixtures/domain_search.json create mode 100644 providers/dns/clouddns/internal/fixtures/login-request.json create mode 100644 providers/dns/clouddns/internal/fixtures/login.json create mode 100644 providers/dns/clouddns/internal/fixtures/publish-request.json create mode 100644 providers/dns/clouddns/internal/fixtures/record_txt-request.json create mode 100644 providers/dns/conoha/internal/fixtures/empty.json create mode 100644 providers/dns/conohav3/internal/fixtures/empty.json create mode 100644 providers/dns/corenetworks/internal/identity_test.go create mode 100644 providers/dns/dnsmadeeasy/internal/fixtures/create_record-request.json create mode 100644 providers/dns/dnsmadeeasy/internal/fixtures/get_records.json create mode 100644 providers/dns/domeneshop/internal/fixtures/create_record-request.json create mode 100644 providers/dns/domeneshop/internal/fixtures/create_record.json create mode 100644 providers/dns/domeneshop/internal/fixtures/delete_record.json create mode 100644 providers/dns/domeneshop/internal/fixtures/getDnsRecords.json create mode 100644 providers/dns/domeneshop/internal/fixtures/getDomains.json create mode 100644 providers/dns/dynu/internal/fixtures/add_new_record-request.json create mode 100644 providers/dns/gandi/internal/client_test.go create mode 100644 providers/dns/gandi/internal/fixtures/add_txt_record-request.xml create mode 100644 providers/dns/gandi/internal/fixtures/clone_zone-request.xml create mode 100644 providers/dns/gandi/internal/fixtures/clone_zone.xml create mode 100644 providers/dns/gandi/internal/fixtures/delete_zone-request.xml create mode 100644 providers/dns/gandi/internal/fixtures/delete_zone.xml create mode 100644 providers/dns/gandi/internal/fixtures/empty.xml create mode 100644 providers/dns/gandi/internal/fixtures/get_zone_id-request.xml create mode 100644 providers/dns/gandi/internal/fixtures/get_zone_id.xml create mode 100644 providers/dns/gandi/internal/fixtures/new_zone_version-request.xml create mode 100644 providers/dns/gandi/internal/fixtures/new_zone_version.xml create mode 100644 providers/dns/gandi/internal/fixtures/set_zone-request.xml create mode 100644 providers/dns/gandi/internal/fixtures/set_zone.xml create mode 100644 providers/dns/gandi/internal/fixtures/set_zone_version-request.xml create mode 100644 providers/dns/gandi/internal/fixtures/set_zone_version.xml create mode 100644 providers/dns/gandiv5/internal/client_test.go create mode 100644 providers/dns/gandiv5/internal/fixtures/add_txt_record_get.json create mode 100644 providers/dns/gandiv5/internal/fixtures/api_response.json create mode 100644 providers/dns/godaddy/internal/fixtures/update_records-request.json create mode 100644 providers/dns/hetzner/internal/fixtures/create_txt_record-request.json create mode 100644 providers/dns/infomaniak/internal/fixtures/create_dns_record-request.json create mode 100644 providers/dns/infomaniak/internal/fixtures/get_domain_name.json create mode 100644 providers/dns/internal/hostingde/fixtures/zoneConfigsFind-request.json create mode 100644 providers/dns/internal/hostingde/fixtures/zoneUpdate-request.json create mode 100644 providers/dns/internal/selectel/fixtures/add_record-request.json create mode 100644 providers/dns/internal/selectel/fixtures/add_record.json create mode 100644 providers/dns/internetbs/internal/fixtures/auth_error.json delete mode 100644 providers/dns/lightsail/mock_server_test.go create mode 100644 providers/dns/mythicbeasts/internal/fixtures/token.json create mode 100644 providers/dns/netcup/internal/client_live_test.go create mode 100644 providers/dns/netcup/internal/fixtures/get_dns_records-request.json create mode 100644 providers/dns/netcup/internal/fixtures/get_dns_records.json create mode 100644 providers/dns/netcup/internal/fixtures/get_dns_records_error.json create mode 100644 providers/dns/netcup/internal/fixtures/get_dns_records_error_unmarshal.json create mode 100644 providers/dns/netcup/internal/fixtures/login-request.json create mode 100644 providers/dns/netcup/internal/fixtures/login.json create mode 100644 providers/dns/netcup/internal/fixtures/login_error.json create mode 100644 providers/dns/netcup/internal/fixtures/login_error_unmarshal.json create mode 100644 providers/dns/netcup/internal/fixtures/logout-request.json create mode 100644 providers/dns/netcup/internal/fixtures/logout.json create mode 100644 providers/dns/netcup/internal/fixtures/logout_error.json create mode 100644 providers/dns/njalla/internal/fixtures/add_record-request.json create mode 100644 providers/dns/njalla/internal/fixtures/add_record.json create mode 100644 providers/dns/njalla/internal/fixtures/auth_error.json create mode 100644 providers/dns/njalla/internal/fixtures/list_records-request.json create mode 100644 providers/dns/njalla/internal/fixtures/list_records.json create mode 100644 providers/dns/njalla/internal/fixtures/remove_record-request.json create mode 100644 providers/dns/njalla/internal/fixtures/remove_record_error_missing_domain.json create mode 100644 providers/dns/njalla/internal/fixtures/remove_record_error_missing_id.json create mode 100644 providers/dns/otc/internal/client_test.go create mode 100644 providers/dns/pdns/internal/fixtures/zone-request.json create mode 100644 providers/dns/rackspace/fixtures/delete.json create mode 100644 providers/dns/rackspace/fixtures/identity.json create mode 100644 providers/dns/rackspace/fixtures/record.json create mode 100644 providers/dns/rackspace/fixtures/record_details.json create mode 100644 providers/dns/rackspace/fixtures/zone_details.json delete mode 100644 providers/dns/rackspace/rackspace_mock_test.go create mode 100644 providers/dns/regru/internal/fixtures/add_txt_record.json create mode 100644 providers/dns/regru/internal/fixtures/add_txt_record_error_auth.json create mode 100644 providers/dns/regru/internal/fixtures/add_txt_record_error_domain.json create mode 100644 providers/dns/regru/internal/fixtures/remove_record.json create mode 100644 providers/dns/regru/internal/fixtures/remove_record_error_auth.json create mode 100644 providers/dns/regru/internal/fixtures/remove_record_error_domain.json create mode 100644 providers/dns/regru/internal/readme.md create mode 100644 providers/dns/route53/fixtures/changeResourceRecordSetsResponse.xml create mode 100644 providers/dns/route53/fixtures/getChangeResponse.xml create mode 100644 providers/dns/route53/fixtures/listHostedZonesByNameResponse.xml delete mode 100644 providers/dns/route53/fixtures_test.go delete mode 100644 providers/dns/route53/mock_test.go create mode 100644 providers/dns/safedns/internal/fixtures/add_record-request.json create mode 100644 providers/dns/safedns/internal/fixtures/add_record.json create mode 100644 providers/dns/safedns/internal/fixtures/error.json create mode 100644 providers/dns/stackpath/internal/fixtures/get_zone_records.json create mode 100644 providers/dns/stackpath/internal/fixtures/get_zones.json create mode 100644 providers/dns/vegadns/fixtures/create_record.json create mode 100644 providers/dns/vegadns/fixtures/record_delete.json create mode 100644 providers/dns/vegadns/fixtures/records.json create mode 100644 providers/dns/vegadns/fixtures/token.json delete mode 100644 providers/dns/vegadns/vegadns_mock_test.go create mode 100644 providers/dns/versio/fixtures/error_failToCreateTXT.json create mode 100644 providers/dns/versio/fixtures/error_failToFindZone.json create mode 100644 providers/dns/versio/fixtures/token.json create mode 100644 providers/dns/versio/internal/fixtures/update-domain-request.json delete mode 100644 providers/dns/versio/versio_mock_test.go delete mode 100644 providers/dns/vinyldns/mock_test.go create mode 100644 providers/dns/yandex/internal/fixtures/add_record.json create mode 100644 providers/dns/yandex/internal/fixtures/add_record_error.json create mode 100644 providers/dns/yandex/internal/fixtures/get_records.json create mode 100644 providers/dns/yandex/internal/fixtures/get_records_error.json create mode 100644 providers/dns/yandex/internal/fixtures/remove_record.json create mode 100644 providers/dns/yandex/internal/fixtures/remove_record_error.json diff --git a/go.mod b/go.mod index 52f4d2eb5..0a15d03c0 100644 --- a/go.mod +++ b/go.mod @@ -35,6 +35,7 @@ require ( github.com/exoscale/egoscale/v3 v3.1.13 github.com/go-jose/go-jose/v4 v4.0.5 github.com/go-viper/mapstructure/v2 v2.2.1 + github.com/google/go-cmp v0.7.0 github.com/google/go-querystring v1.1.0 github.com/gophercloud/gophercloud v1.14.1 github.com/gophercloud/utils v0.0.0-20231010081019-80377eca5d56 diff --git a/platform/tester/servermock/builder.go b/platform/tester/servermock/builder.go new file mode 100644 index 000000000..e3b41e5c3 --- /dev/null +++ b/platform/tester/servermock/builder.go @@ -0,0 +1,72 @@ +package servermock + +import ( + "net/http" + "net/http/httptest" + "slices" + "testing" + + "github.com/stretchr/testify/require" +) + +// Link represents a middleware interface, enabling middleware chaining. +type Link interface { + Bind(next http.Handler) http.Handler +} + +// LinkFunc defines a function type [Link]. +type LinkFunc func(next http.Handler) http.Handler + +func (f LinkFunc) Bind(next http.Handler) http.Handler { + return f(next) +} + +// ClientBuilder defines a function type for creating a client of type T based on a httptest.Server instance. +type ClientBuilder[T any] func(server *httptest.Server) (T, error) + +// Builder is a type that facilitates the construction of testable HTTP clients and server. +// It allows defining routes, attaching middleware, and creating custom HTTP clients. +type Builder[T any] struct { + mux *http.ServeMux + chain []Link + + clientBuilder ClientBuilder[T] +} + +func NewBuilder[T any](clientBuilder ClientBuilder[T], chain ...Link) *Builder[T] { + return &Builder[T]{ + mux: http.NewServeMux(), + chain: chain, + clientBuilder: clientBuilder, + } +} + +func (b *Builder[T]) Route(pattern string, handler http.Handler, chain ...Link) *Builder[T] { + if handler == nil { + handler = Noop() + } + + for _, link := range slices.Backward(b.chain) { + handler = link.Bind(handler) + } + + for _, link := range slices.Backward(chain) { + handler = link.Bind(handler) + } + + b.mux.Handle(pattern, handler) + + return b +} + +func (b *Builder[T]) Build(t *testing.T) T { + t.Helper() + + server := httptest.NewServer(b.mux) + t.Cleanup(server.Close) + + client, err := b.clientBuilder(server) + require.NoError(t, err) + + return client +} diff --git a/platform/tester/servermock/handler_dump.go b/platform/tester/servermock/handler_dump.go new file mode 100644 index 000000000..83f902980 --- /dev/null +++ b/platform/tester/servermock/handler_dump.go @@ -0,0 +1,20 @@ +package servermock + +import ( + "fmt" + "net/http" + "net/http/httputil" +) + +// DumpRequest logs the full HTTP request to the console, including the body if present. +func DumpRequest() http.HandlerFunc { + return func(rw http.ResponseWriter, req *http.Request) { + dump, err := httputil.DumpRequest(req, true) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + fmt.Println(string(dump)) + } +} diff --git a/platform/tester/servermock/handler_file.go b/platform/tester/servermock/handler_file.go new file mode 100644 index 000000000..d826c648a --- /dev/null +++ b/platform/tester/servermock/handler_file.go @@ -0,0 +1,77 @@ +package servermock + +import ( + "io" + "net/http" + "os" + "path/filepath" + "slices" +) + +// ResponseFromFileHandler handles HTTP responses using the content of a file. +type ResponseFromFileHandler struct { + statusCode int + headers http.Header + filename string +} + +func ResponseFromFile(filename string) *ResponseFromFileHandler { + return &ResponseFromFileHandler{ + statusCode: http.StatusOK, + headers: http.Header{}, + filename: filename, + } +} + +func ResponseFromFixture(filename string) *ResponseFromFileHandler { + return ResponseFromFile(filepath.Join("fixtures", filename)) +} + +func (h *ResponseFromFileHandler) ServeHTTP(rw http.ResponseWriter, _ *http.Request) { + for k, values := range h.headers { + for _, v := range values { + rw.Header().Add(k, v) + } + } + + if h.filename == "" { + rw.WriteHeader(h.statusCode) + return + } + + if filepath.Ext(h.filename) == ".json" { + rw.Header().Set(contentTypeHeader, applicationJSONMimeType) + } + + file, err := os.Open(h.filename) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + defer func() { _ = file.Close() }() + + rw.WriteHeader(h.statusCode) + + _, err = io.Copy(rw, file) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } +} + +func (h *ResponseFromFileHandler) WithStatusCode(status int) *ResponseFromFileHandler { + if h.statusCode >= http.StatusContinue { + h.statusCode = status + } + + return h +} + +func (h *ResponseFromFileHandler) WithHeader(name, value string, values ...string) *ResponseFromFileHandler { + for _, v := range slices.Concat([]string{value}, values) { + h.headers.Add(name, v) + } + + return h +} diff --git a/platform/tester/servermock/handler_json.go b/platform/tester/servermock/handler_json.go new file mode 100644 index 000000000..f1c2aa9ce --- /dev/null +++ b/platform/tester/servermock/handler_json.go @@ -0,0 +1,39 @@ +package servermock + +import ( + "encoding/json" + "net/http" +) + +// JSONEncodeHandler is a handler that encodes data into JSON and writes it to an HTTP response. +type JSONEncodeHandler struct { + data any + statusCode int +} + +func JSONEncode(data any) *JSONEncodeHandler { + return &JSONEncodeHandler{ + data: data, + statusCode: http.StatusOK, + } +} + +func (h *JSONEncodeHandler) ServeHTTP(rw http.ResponseWriter, _ *http.Request) { + rw.Header().Set(contentTypeHeader, applicationJSONMimeType) + + rw.WriteHeader(h.statusCode) + + err := json.NewEncoder(rw).Encode(h.data) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } +} + +func (h *JSONEncodeHandler) WithStatusCode(status int) *JSONEncodeHandler { + if h.statusCode >= http.StatusContinue { + h.statusCode = status + } + + return h +} diff --git a/platform/tester/servermock/handler_noop.go b/platform/tester/servermock/handler_noop.go new file mode 100644 index 000000000..6df5164e6 --- /dev/null +++ b/platform/tester/servermock/handler_noop.go @@ -0,0 +1,45 @@ +package servermock + +import ( + "net/http" + "slices" +) + +// NoopHandler is a simple HTTP handler that responds without processing requests. +type NoopHandler struct { + statusCode int + headers http.Header +} + +func Noop() *NoopHandler { + return &NoopHandler{ + statusCode: http.StatusOK, + headers: http.Header{}, + } +} + +func (h *NoopHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) { + for k, values := range h.headers { + for _, v := range values { + rw.Header().Add(k, v) + } + } + + rw.WriteHeader(h.statusCode) +} + +func (h *NoopHandler) WithStatusCode(status int) *NoopHandler { + if h.statusCode >= http.StatusContinue { + h.statusCode = status + } + + return h +} + +func (h *NoopHandler) WithHeader(name, value string, values ...string) *NoopHandler { + for _, v := range slices.Concat([]string{value}, values) { + h.headers.Add(name, v) + } + + return h +} diff --git a/platform/tester/servermock/handler_raw.go b/platform/tester/servermock/handler_raw.go new file mode 100644 index 000000000..d7c68f396 --- /dev/null +++ b/platform/tester/servermock/handler_raw.go @@ -0,0 +1,61 @@ +package servermock + +import ( + "net/http" + "slices" +) + +// RawResponseHandler is a custom HTTP handler that serves raw response data. +type RawResponseHandler struct { + statusCode int + headers http.Header + data []byte +} + +func RawResponse(data []byte) *RawResponseHandler { + return &RawResponseHandler{ + statusCode: http.StatusOK, + headers: http.Header{}, + data: data, + } +} + +func RawStringResponse(data string) *RawResponseHandler { + return RawResponse([]byte(data)) +} + +func (h *RawResponseHandler) ServeHTTP(rw http.ResponseWriter, _ *http.Request) { + for k, values := range h.headers { + for _, v := range values { + rw.Header().Add(k, v) + } + } + + rw.WriteHeader(h.statusCode) + + if len(h.data) == 0 { + return + } + + _, err := rw.Write(h.data) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } +} + +func (h *RawResponseHandler) WithStatusCode(status int) *RawResponseHandler { + if h.statusCode >= http.StatusContinue { + h.statusCode = status + } + + return h +} + +func (h *RawResponseHandler) WithHeader(name, value string, values ...string) *RawResponseHandler { + for _, v := range slices.Concat([]string{value}, values) { + h.headers.Add(name, v) + } + + return h +} diff --git a/platform/tester/servermock/link_form.go b/platform/tester/servermock/link_form.go new file mode 100644 index 000000000..e7541cefa --- /dev/null +++ b/platform/tester/servermock/link_form.go @@ -0,0 +1,94 @@ +package servermock + +import ( + "fmt" + "net/http" + "net/url" + "regexp" + "slices" +) + +// FormLink is a type used for validating and processing form data in HTTP requests. +// It supports strict validation, predefined values, and regex-based checks to ensure form compliance. +type FormLink struct { + values url.Values + regexes map[string]*regexp.Regexp + strict bool + usePostForm bool + statusCode int +} + +func CheckForm() *FormLink { + return &FormLink{ + values: url.Values{}, + regexes: map[string]*regexp.Regexp{}, + statusCode: http.StatusBadRequest, + } +} + +func (l *FormLink) Bind(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + err := req.ParseForm() + if err != nil { + http.Error(rw, err.Error(), l.statusCode) + return + } + + form := req.Form + if l.usePostForm { + form = req.PostForm + } + + if l.strict { + if len(form) != len(l.values)+len(l.regexes) { + msg := fmt.Sprintf("invalid query parameters, got %v, want %v", req.Form, l.values) + http.Error(rw, msg, l.statusCode) + return + } + } + + for k, v := range l.values { + value := form[k] + if !slices.Equal(v, value) { + msg := fmt.Sprintf("invalid %q form value, got %q, want %q", k, value, v) + http.Error(rw, msg, l.statusCode) + return + } + } + + for k, exp := range l.regexes { + value := form.Get(k) + if !exp.MatchString(value) { + msg := fmt.Sprintf("invalid %q form value, %q doesn't match to %q", k, value, exp) + http.Error(rw, msg, l.statusCode) + return + } + } + + next.ServeHTTP(rw, req) + }) +} + +func (l *FormLink) Strict() *FormLink { + l.strict = true + + return l +} + +func (l *FormLink) UsePostForm() *FormLink { + l.usePostForm = true + + return l +} + +func (l *FormLink) With(name, value string) *FormLink { + l.values.Set(name, value) + + return l +} + +func (l *FormLink) WithRegexp(name, exp string) *FormLink { + l.regexes[name] = regexp.MustCompile(exp) + + return l +} diff --git a/platform/tester/servermock/link_headers.go b/platform/tester/servermock/link_headers.go new file mode 100644 index 000000000..821c737fe --- /dev/null +++ b/platform/tester/servermock/link_headers.go @@ -0,0 +1,177 @@ +package servermock + +import ( + "fmt" + "net/http" + "regexp" + "slices" +) + +const ( + authorizationHeader = "Authorization" + contentTypeHeader = "Content-Type" + acceptHeader = "Accept" +) + +const ( + applicationJSONMimeType = "application/json" + applicationFormMimeType = "application/x-www-form-urlencoded" +) + +type basicAuth struct { + username, password string +} + +// HeaderLink validates HTTP request headers. +type HeaderLink struct { + values http.Header + regexes map[string]*regexp.Regexp + json bool + basicAuth *basicAuth + statusCode int +} + +func CheckHeader() *HeaderLink { + return &HeaderLink{ + values: http.Header{}, + regexes: map[string]*regexp.Regexp{}, + statusCode: http.StatusBadRequest, + } +} + +func (l *HeaderLink) Bind(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + for k, v := range l.values { + err := checkHeader(req, k, v) + if err != nil { + http.Error(rw, err.Error(), l.statusCode) + return + } + } + + for k, exp := range l.regexes { + value := req.Header.Get(k) + + if !exp.MatchString(value) { + msg := fmt.Sprintf("invalid %q header value, %q doesn't match to %q", k, value, exp) + http.Error(rw, msg, l.statusCode) + return + } + } + + if l.json && !l.checkJSONHeaders(rw, req) { + return + } + + if l.basicAuth != nil && !l.checkBasicAuth(rw, req) { + return + } + + next.ServeHTTP(rw, req) + }) +} + +func (l *HeaderLink) With(name, value string, values ...string) *HeaderLink { + for _, v := range slices.Concat([]string{value}, values) { + l.values.Add(name, v) + } + + return l +} + +func (l *HeaderLink) WithRegexp(name, exp string) *HeaderLink { + l.regexes[name] = regexp.MustCompile(exp) + + return l +} + +func (l *HeaderLink) WithJSONHeaders() *HeaderLink { + l.json = true + + return l +} + +func (l *HeaderLink) WithContentTypeFromURLEncoded() *HeaderLink { + l.values.Set(contentTypeHeader, applicationFormMimeType) + + return l +} + +func (l *HeaderLink) WithContentType(value string) *HeaderLink { + l.values.Set(contentTypeHeader, value) + + return l +} + +func (l *HeaderLink) WithAccept(value string) *HeaderLink { + l.values.Set(acceptHeader, value) + + return l +} + +func (l *HeaderLink) WithAuthorization(value string) *HeaderLink { + l.values.Set(authorizationHeader, value) + + return l +} + +func (l *HeaderLink) WithStatusCode(status int) *HeaderLink { + if l.statusCode >= http.StatusContinue { + l.statusCode = status + } + + return l +} + +func (l *HeaderLink) WithBasicAuth(username, password string) *HeaderLink { + l.basicAuth = &basicAuth{username: username, password: password} + + return l +} + +func (l *HeaderLink) checkBasicAuth(rw http.ResponseWriter, req *http.Request) bool { + usr, pwd, ok := req.BasicAuth() + if !ok { + http.Error(rw, "missing Basic auth", l.statusCode) + + return false + } + + if usr != l.basicAuth.username || pwd != l.basicAuth.password { + msg := fmt.Sprintf("invalid credentials: got [username: %q, password: %q], want [username: %q, password: %q]", + usr, pwd, l.basicAuth.username, l.basicAuth.password) + http.Error(rw, msg, l.statusCode) + + return false + } + + return true +} + +func (l *HeaderLink) checkJSONHeaders(rw http.ResponseWriter, req *http.Request) bool { + err := checkHeader(req, acceptHeader, []string{applicationJSONMimeType}) + if err != nil { + http.Error(rw, err.Error(), l.statusCode) + + return false + } + + if req.ContentLength > 0 { + err = checkHeader(req, contentTypeHeader, []string{applicationJSONMimeType}) + if err != nil { + http.Error(rw, err.Error(), l.statusCode) + + return false + } + } + + return true +} + +func checkHeader(req *http.Request, k string, v []string) error { + if !slices.Equal(req.Header[k], v) { + return fmt.Errorf("invalid %q header value, got %q, want %q", k, req.Header[k], v) + } + + return nil +} diff --git a/platform/tester/servermock/link_query.go b/platform/tester/servermock/link_query.go new file mode 100644 index 000000000..00d7450ae --- /dev/null +++ b/platform/tester/servermock/link_query.go @@ -0,0 +1,97 @@ +package servermock + +import ( + "fmt" + "net/http" + "net/url" + "regexp" +) + +// QueryParameterLink validates query parameters in HTTP requests. +// The strict flag enforces exact matches with specified query parameters. +type QueryParameterLink struct { + values map[string]string + regexes map[string]*regexp.Regexp + strict bool + statusCode int +} + +func CheckQueryParameter() *QueryParameterLink { + return &QueryParameterLink{ + values: map[string]string{}, + regexes: map[string]*regexp.Regexp{}, + statusCode: http.StatusBadRequest, + } +} + +func (l *QueryParameterLink) Bind(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + query := req.URL.Query() + + if l.strict { + if len(query) != len(l.values)+len(l.regexes) { + msg := fmt.Sprintf("invalid query parameters, got %v, want %v", query, l.values) + http.Error(rw, msg, l.statusCode) + return + } + } + + for k, v := range l.values { + p := query.Get(k) + if p != v { + msg := fmt.Sprintf("invalid %q query parameter value, got %q, want %q", k, p, v) + http.Error(rw, msg, l.statusCode) + return + } + } + + for k, exp := range l.regexes { + value := query.Get(k) + if !exp.MatchString(value) { + msg := fmt.Sprintf("invalid %q query parameter value, %q doesn't match to %q", k, value, exp) + http.Error(rw, msg, l.statusCode) + return + } + } + + next.ServeHTTP(rw, req) + }) +} + +func (l *QueryParameterLink) Strict() *QueryParameterLink { + l.strict = true + + return l +} + +func (l *QueryParameterLink) With(name, value string) *QueryParameterLink { + l.values[name] = value + + return l +} + +func (l *QueryParameterLink) WithRegexp(name, exp string) *QueryParameterLink { + l.regexes[name] = regexp.MustCompile(exp) + + return l +} + +func (l *QueryParameterLink) WithValues(values url.Values) *QueryParameterLink { + for k, v := range values { + if len(v) != 1 { + continue + } + + l.values[k] = v[0] + } + + return l +} + +func (l *QueryParameterLink) WithStatusCode(status int) *QueryParameterLink { + if l.statusCode >= http.StatusContinue { + l.statusCode = status + } + + return l +} diff --git a/platform/tester/servermock/link_request_body.go b/platform/tester/servermock/link_request_body.go new file mode 100644 index 000000000..67ab4ae3f --- /dev/null +++ b/platform/tester/servermock/link_request_body.go @@ -0,0 +1,89 @@ +package servermock + +import ( + "bytes" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "slices" +) + +// RequestBodyLink represents a handler utility to validate HTTP request bodies against a predefined byte slice. +type RequestBodyLink struct { + body []byte + filename string + ignoreWhitespace bool +} + +// CheckRequestBody creates a [RequestBodyLink] initialized with the provided request body string. +func CheckRequestBody(body string) *RequestBodyLink { + return &RequestBodyLink{body: []byte(body)} +} + +// CheckRequestBodyFromFile creates a [RequestBodyLink] initialized with the provided request body file. +func CheckRequestBodyFromFile(filename string) *RequestBodyLink { + return &RequestBodyLink{filename: filename} +} + +func (l *RequestBodyLink) Bind(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + if req.ContentLength == 0 { + http.Error(rw, fmt.Sprintf("%s: empty request body", req.URL.Path), http.StatusBadRequest) + return + } + + body, err := io.ReadAll(req.Body) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + _ = req.Body.Close() + + expectedRaw := slices.Clone(l.body) + + if l.filename != "" { + expectedRaw, err = os.ReadFile(filepath.Join("fixtures", l.filename)) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + } + + if len(expectedRaw) == 0 { + http.Error(rw, fmt.Sprintf("%s: empty expected request body", req.URL.Path), http.StatusBadRequest) + return + } + + if l.ignoreWhitespace { + body = trimLineSpace(body) + expectedRaw = trimLineSpace(expectedRaw) + } + + if !bytes.Equal(bytes.TrimSpace(expectedRaw), bytes.TrimSpace(body)) { + msg := fmt.Sprintf("%s: request body differences: got: %s, want: %s", req.URL.Path, + string(bytes.TrimSpace(body)), string(bytes.TrimSpace(expectedRaw))) + http.Error(rw, msg, http.StatusBadRequest) + return + } + + next.ServeHTTP(rw, req) + }) +} + +func (l *RequestBodyLink) IgnoreWhitespace() *RequestBodyLink { + l.ignoreWhitespace = true + + return l +} + +func trimLineSpace(body []byte) []byte { + buf := bytes.NewBuffer(nil) + for line := range bytes.Lines(body) { + buf.Write(bytes.TrimSpace(line)) + } + + return buf.Bytes() +} diff --git a/platform/tester/servermock/link_request_body_json.go b/platform/tester/servermock/link_request_body_json.go new file mode 100644 index 000000000..1d1fecce9 --- /dev/null +++ b/platform/tester/servermock/link_request_body_json.go @@ -0,0 +1,99 @@ +package servermock + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "slices" + + "github.com/google/go-cmp/cmp" +) + +// RequestBodyJSONLink validates JSON request bodies. +type RequestBodyJSONLink struct { + body []byte + filename string + data any +} + +// CheckRequestJSONBody creates a [RequestBodyJSONLink] initialized with a string. +func CheckRequestJSONBody(body string) *RequestBodyJSONLink { + return &RequestBodyJSONLink{body: []byte(body)} +} + +// CheckRequestJSONBodyFromStruct creates a [RequestBodyJSONLink] initialized with a struct. +func CheckRequestJSONBodyFromStruct(data any) *RequestBodyJSONLink { + return &RequestBodyJSONLink{data: data} +} + +// CheckRequestJSONBodyFromFile creates a [RequestBodyJSONLink] initialized with the provided request body file. +func CheckRequestJSONBodyFromFile(filename string) *RequestBodyJSONLink { + return &RequestBodyJSONLink{filename: filename} +} + +func (l *RequestBodyJSONLink) Bind(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + if req.ContentLength == 0 { + http.Error(rw, fmt.Sprintf("%s: empty request body", req.URL.Path), http.StatusBadRequest) + return + } + + body, err := io.ReadAll(req.Body) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + _ = req.Body.Close() + + var expected, actual any + + expectedRaw := slices.Clone(l.body) + + switch { + case l.filename != "": + expectedRaw, err = os.ReadFile(filepath.Join("fixtures", l.filename)) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + case l.data != nil: + expectedRaw, err = json.Marshal(l.data) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + } + + if len(expectedRaw) == 0 { + http.Error(rw, fmt.Sprintf("%s: empty expected request body", req.URL.Path), http.StatusBadRequest) + return + } + + err = json.Unmarshal(expectedRaw, &expected) + if err != nil { + msg := fmt.Sprintf("%s: the expected request body is not valid JSON: %v", req.URL.Path, err) + http.Error(rw, msg, http.StatusBadRequest) + return + } + + err = json.Unmarshal(body, &actual) + if err != nil { + msg := fmt.Sprintf("%s: request body is not valid JSON: %v", req.URL.Path, err) + http.Error(rw, msg, http.StatusBadRequest) + return + } + + if !cmp.Equal(actual, expected) { + msg := fmt.Sprintf("%s: request body differences: %s", req.URL.Path, cmp.Diff(actual, expected)) + http.Error(rw, msg, http.StatusBadRequest) + return + } + + next.ServeHTTP(rw, req) + }) +} diff --git a/providers/dns/acmedns/acmedns_test.go b/providers/dns/acmedns/acmedns_test.go index 3bc847b6d..e50c89d56 100644 --- a/providers/dns/acmedns/acmedns_test.go +++ b/providers/dns/acmedns/acmedns_test.go @@ -5,6 +5,7 @@ import ( "net/http/httptest" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/nrdcg/goacmedns" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -166,11 +167,17 @@ func TestPresent_httpStorage(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - mux := http.NewServeMux() - server := httptest.NewServer(mux) + config := servermock.NewBuilder(func(server *httptest.Server) (*Config, error) { + cfg := NewDefaultConfig() + cfg.StorageBaseURL = server.URL - config := NewDefaultConfig() - config.StorageBaseURL = server.URL + return cfg, nil + }). + // Fetch + Route("GET /example.com", servermock.Noop().WithStatusCode(http.StatusNotFound)). + // Put + Route("POST /example.com", servermock.Noop().WithStatusCode(test.StatusCode)). + Build(t) p, err := NewDNSProviderConfig(config) require.NoError(t, err) @@ -178,16 +185,6 @@ func TestPresent_httpStorage(t *testing.T) { client := newMockClient().WithRegisterAccount(egTestAccount) p.client = client - // Fetch - mux.HandleFunc("GET /example.com", func(rw http.ResponseWriter, reg *http.Request) { - rw.WriteHeader(http.StatusNotFound) - }) - - // Put - mux.HandleFunc("POST /example.com", func(rw http.ResponseWriter, req *http.Request) { - rw.WriteHeader(test.StatusCode) - }) - err = p.Present(egDomain, "foo", egKeyAuth) if test.ExpectedError != nil { assert.Equal(t, test.ExpectedError, err) @@ -225,22 +222,21 @@ func TestRegister_httpStorage(t *testing.T) { for _, test := range testCases { t.Run(test.Name, func(t *testing.T) { - mux := http.NewServeMux() - server := httptest.NewServer(mux) + config := servermock.NewBuilder(func(server *httptest.Server) (*Config, error) { + cfg := NewDefaultConfig() + cfg.StorageBaseURL = server.URL - config := NewDefaultConfig() - config.StorageBaseURL = server.URL + return cfg, nil + }). + // Put + Route("POST /example.com", servermock.Noop().WithStatusCode(test.StatusCode)). + Build(t) p, err := NewDNSProviderConfig(config) require.NoError(t, err) p.client = newMockClient().WithRegisterAccount(egTestAccount) - // Put - mux.HandleFunc("POST /example.com", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(test.StatusCode) - }) - acc, err := p.register(t.Context(), egDomain, egFQDN) if test.ExpectedError != nil { assert.Equal(t, test.ExpectedError, err) diff --git a/providers/dns/acmedns/internal/fixtures/request-body.json b/providers/dns/acmedns/internal/fixtures/request-body.json new file mode 100644 index 000000000..d29cebc5b --- /dev/null +++ b/providers/dns/acmedns/internal/fixtures/request-body.json @@ -0,0 +1,7 @@ +{ + "fulldomain": "foo.example.com", + "subdomain": "foo", + "username": "user", + "password": "secret", + "server_url": "https://example.com" +} diff --git a/providers/dns/acmedns/internal/http_storage_test.go b/providers/dns/acmedns/internal/http_storage_test.go index 14a5fd97c..0be6dd949 100644 --- a/providers/dns/acmedns/internal/http_storage_test.go +++ b/providers/dns/acmedns/internal/http_storage_test.go @@ -1,57 +1,35 @@ package internal import ( - "io" "net/http" "net/http/httptest" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/nrdcg/goacmedns" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, pattern, filename string, statusCode int) *HTTPStorage { - t.Helper() +func mockBuilder() *servermock.Builder[*HTTPStorage] { + return servermock.NewBuilder[*HTTPStorage]( + func(server *httptest.Server) (*HTTPStorage, error) { + storage, err := NewHTTPStorage(server.URL) + if err != nil { + return nil, err + } - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) + storage.client = server.Client() - mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) { - if filename == "" { - rw.WriteHeader(statusCode) - return - } - - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = file.Close() }() - - rw.WriteHeader(statusCode) - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - storage, err := NewHTTPStorage(server.URL) - require.NoError(t, err) - - storage.client = server.Client() - - return storage + return storage, nil + }, + servermock.CheckHeader().WithJSONHeaders()) } func TestHTTPStorage_Fetch(t *testing.T) { - storage := setupTest(t, "GET /example.com", "fetch.json", http.StatusOK) + storage := mockBuilder(). + Route("GET /example.com", servermock.ResponseFromFixture("fetch.json")). + Build(t) account, err := storage.Fetch(t.Context(), "example.com") require.NoError(t, err) @@ -68,14 +46,20 @@ func TestHTTPStorage_Fetch(t *testing.T) { } func TestHTTPStorage_Fetch_error(t *testing.T) { - storage := setupTest(t, "GET /example.com", "error.json", http.StatusInternalServerError) + storage := mockBuilder(). + Route("GET /example.com", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusInternalServerError)). + Build(t) _, err := storage.Fetch(t.Context(), "example.com") require.Error(t, err) } func TestHTTPStorage_FetchAll(t *testing.T) { - storage := setupTest(t, "GET /", "fetch-all.json", http.StatusOK) + storage := mockBuilder(). + Route("GET /", servermock.ResponseFromFixture("fetch-all.json")). + Build(t) account, err := storage.FetchAll(t.Context()) require.NoError(t, err) @@ -101,14 +85,21 @@ func TestHTTPStorage_FetchAll(t *testing.T) { } func TestHTTPStorage_FetchAll_error(t *testing.T) { - storage := setupTest(t, "GET /", "error.json", http.StatusInternalServerError) + storage := mockBuilder(). + Route("GET /", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusInternalServerError)). + Build(t) _, err := storage.FetchAll(t.Context()) require.Error(t, err) } func TestHTTPStorage_Put(t *testing.T) { - storage := setupTest(t, "POST /example.com", "", http.StatusOK) + storage := mockBuilder(). + Route("POST /example.com", nil, + servermock.CheckRequestJSONBodyFromFile("request-body.json")). + Build(t) account := goacmedns.Account{ FullDomain: "foo.example.com", @@ -123,7 +114,11 @@ func TestHTTPStorage_Put(t *testing.T) { } func TestHTTPStorage_Put_error(t *testing.T) { - storage := setupTest(t, "POST /example.com", "error.json", http.StatusInternalServerError) + storage := mockBuilder(). + Route("POST /example.com", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusInternalServerError)). + Build(t) account := goacmedns.Account{ FullDomain: "foo.example.com", @@ -138,7 +133,12 @@ func TestHTTPStorage_Put_error(t *testing.T) { } func TestHTTPStorage_Put_CNAME_created(t *testing.T) { - storage := setupTest(t, "POST /example.com", "", http.StatusCreated) + storage := mockBuilder(). + Route("POST /example.com", + servermock.Noop(). + WithStatusCode(http.StatusCreated), + servermock.CheckRequestJSONBodyFromFile("request-body.json")). + Build(t) account := goacmedns.Account{ FullDomain: "foo.example.com", diff --git a/providers/dns/allinkl/internal/client_test.go b/providers/dns/allinkl/internal/client_test.go index 6ccb1ebf6..5954e2463 100644 --- a/providers/dns/allinkl/internal/client_test.go +++ b/providers/dns/allinkl/internal/client_test.go @@ -1,27 +1,28 @@ package internal import ( - "fmt" - "io" - "net/http" "net/http/httptest" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestClient_GetDNSSettings(t *testing.T) { - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc("/", testHandler("get_dns_settings.xml")) - +func setupClient(server *httptest.Server) (*Client, error) { client := NewClient("user") client.baseURL = server.URL + client.HTTPClient = server.Client() + + return client, nil +} + +func TestClient_GetDNSSettings(t *testing.T) { + client := servermock.NewBuilder[*Client](setupClient). + Route("POST /", servermock.ResponseFromFixture("get_dns_settings.xml"), + servermock.CheckRequestBodyFromFile("get_dns_settings-request.xml"). + IgnoreWhitespace()). + Build(t) records, err := client.GetDNSSettings(mockContext(t), "example.com", "") require.NoError(t, err) @@ -96,14 +97,11 @@ func TestClient_GetDNSSettings(t *testing.T) { } func TestClient_AddDNSSettings(t *testing.T) { - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc("/", testHandler("add_dns_settings.xml")) - - client := NewClient("user") - client.baseURL = server.URL + client := servermock.NewBuilder[*Client](setupClient). + Route("POST /", servermock.ResponseFromFixture("add_dns_settings.xml"), + servermock.CheckRequestBodyFromFile("add_dns_settings-request.xml"). + IgnoreWhitespace()). + Build(t) record := DNSRequest{ ZoneHost: "42cnc.de.", @@ -119,40 +117,14 @@ func TestClient_AddDNSSettings(t *testing.T) { } func TestClient_DeleteDNSSettings(t *testing.T) { - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc("/", testHandler("delete_dns_settings.xml")) - - client := NewClient("user") - client.baseURL = server.URL + client := servermock.NewBuilder[*Client](setupClient). + Route("POST /", servermock.ResponseFromFixture("delete_dns_settings.xml"), + servermock.CheckRequestBodyFromFile("delete_dns_settings-request.xml"). + IgnoreWhitespace()). + Build(t) r, err := client.DeleteDNSSettings(mockContext(t), "57347450") require.NoError(t, err) assert.Equal(t, "TRUE", r) } - -func testHandler(filename string) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - } -} diff --git a/providers/dns/allinkl/internal/fixtures/add_dns_settings-request.xml b/providers/dns/allinkl/internal/fixtures/add_dns_settings-request.xml new file mode 100644 index 000000000..e8cd12633 --- /dev/null +++ b/providers/dns/allinkl/internal/fixtures/add_dns_settings-request.xml @@ -0,0 +1,7 @@ + + + + {"kas_login":"user","kas_auth_type":"session","kas_auth_data":"593959ca04f0de9689b586c6a647d15d","kas_action":"add_dns_settings","KasRequestParams":{"zone_host":"42cnc.de.","record_type":"TXT","record_name":"lego","record_data":"abcdefgh","record_aux":0}} + + + diff --git a/providers/dns/allinkl/internal/fixtures/delete_dns_settings-request.xml b/providers/dns/allinkl/internal/fixtures/delete_dns_settings-request.xml new file mode 100644 index 000000000..a306a98a7 --- /dev/null +++ b/providers/dns/allinkl/internal/fixtures/delete_dns_settings-request.xml @@ -0,0 +1,7 @@ + + + + {"kas_login":"user","kas_auth_type":"session","kas_auth_data":"593959ca04f0de9689b586c6a647d15d","kas_action":"delete_dns_settings","KasRequestParams":{"record_id":"57347450"}} + + + diff --git a/providers/dns/allinkl/internal/fixtures/get_dns_settings-request.xml b/providers/dns/allinkl/internal/fixtures/get_dns_settings-request.xml new file mode 100644 index 000000000..b44941d2b --- /dev/null +++ b/providers/dns/allinkl/internal/fixtures/get_dns_settings-request.xml @@ -0,0 +1,7 @@ + + + + {"kas_login":"user","kas_auth_type":"session","kas_auth_data":"593959ca04f0de9689b586c6a647d15d","kas_action":"get_dns_settings","KasRequestParams":{"zone_host":"example.com"}} + + + diff --git a/providers/dns/allinkl/internal/identity_test.go b/providers/dns/allinkl/internal/identity_test.go index 2ef0a4ca4..dc55506f2 100644 --- a/providers/dns/allinkl/internal/identity_test.go +++ b/providers/dns/allinkl/internal/identity_test.go @@ -2,14 +2,21 @@ package internal import ( "context" - "net/http" "net/http/httptest" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +func setupIdentifierClient(server *httptest.Server) (*Identifier, error) { + client := NewIdentifier("user", "secret") + client.authEndpoint = server.URL + + return client, nil +} + func mockContext(t *testing.T) context.Context { t.Helper() @@ -17,14 +24,9 @@ func mockContext(t *testing.T) context.Context { } func TestIdentifier_Authentication(t *testing.T) { - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc("/", testHandler("auth.xml")) - - client := NewIdentifier("user", "secret") - client.authEndpoint = server.URL + client := servermock.NewBuilder[*Identifier](setupIdentifierClient). + Route("POST /", servermock.ResponseFromFixture("auth.xml")). + Build(t) credentialToken, err := client.Authentication(t.Context(), 60, false) require.NoError(t, err) @@ -33,14 +35,9 @@ func TestIdentifier_Authentication(t *testing.T) { } func TestIdentifier_Authentication_error(t *testing.T) { - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc("/", testHandler("auth_fault.xml")) - - client := NewIdentifier("user", "secret") - client.authEndpoint = server.URL + client := servermock.NewBuilder[*Identifier](setupIdentifierClient). + Route("POST /", servermock.ResponseFromFixture("auth_fault.xml")). + Build(t) _, err := client.Authentication(t.Context(), 60, false) require.Error(t, err) diff --git a/providers/dns/arvancloud/internal/client_test.go b/providers/dns/arvancloud/internal/client_test.go index 2930dcb3d..38cb740c1 100644 --- a/providers/dns/arvancloud/internal/client_test.go +++ b/providers/dns/arvancloud/internal/client_test.go @@ -1,64 +1,39 @@ package internal import ( - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, apiKey string) (*Client, *http.ServeMux) { - t.Helper() +func mockBuilder(apiKey string) *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient(apiKey) + client.baseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := NewClient(apiKey) - client.baseURL, _ = url.Parse(server.URL) - client.HTTPClient = server.Client() - - return client, mux + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization(apiKey)) } func TestClient_GetTxtRecord(t *testing.T) { const apiKey = "myKeyA" - client, mux := setupTest(t, apiKey) - const domain = "example.com" - mux.HandleFunc("/cdn/4.0/domains/"+domain+"/dns-records", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - auth := req.Header.Get(authorizationHeader) - if auth != apiKey { - http.Error(rw, fmt.Sprintf("invalid API key: %s", auth), http.StatusUnauthorized) - return - } - - file, err := os.Open("./fixtures/get_txt_record.json") - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder(apiKey). + Route("GET /cdn/4.0/domains/"+domain+"/dns-records", + servermock.ResponseFromFixture("get_txt_record.json"), + servermock.CheckQueryParameter().With("search", "acme-challenge")). + Build(t) _, err := client.GetTxtRecord(t.Context(), domain, "_acme-challenge", "txtxtxt") require.NoError(t, err) @@ -67,36 +42,14 @@ func TestClient_GetTxtRecord(t *testing.T) { func TestClient_CreateRecord(t *testing.T) { const apiKey = "myKeyB" - client, mux := setupTest(t, apiKey) - const domain = "example.com" - mux.HandleFunc("/cdn/4.0/domains/"+domain+"/dns-records", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - auth := req.Header.Get(authorizationHeader) - if auth != apiKey { - http.Error(rw, fmt.Sprintf("invalid API key: %s", auth), http.StatusUnauthorized) - return - } - - file, err := os.Open("./fixtures/create_txt_record.json") - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - rw.WriteHeader(http.StatusCreated) - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder(apiKey). + Route("POST /cdn/4.0/domains/"+domain+"/dns-records", + servermock.ResponseFromFixture("create_txt_record.json"). + WithStatusCode(http.StatusCreated), + servermock.CheckRequestJSONBodyFromFile("create_record-request.json")). + Build(t) record := DNSRecord{ Name: "_acme-challenge", @@ -128,23 +81,12 @@ func TestClient_CreateRecord(t *testing.T) { func TestClient_DeleteRecord(t *testing.T) { const apiKey = "myKeyC" - client, mux := setupTest(t, apiKey) - const domain = "example.com" const recordID = "recordId" - mux.HandleFunc("/cdn/4.0/domains/"+domain+"/dns-records/"+recordID, func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodDelete { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - auth := req.Header.Get(authorizationHeader) - if auth != apiKey { - http.Error(rw, fmt.Sprintf("invalid API key: %s", auth), http.StatusUnauthorized) - return - } - }) + client := mockBuilder(apiKey). + Route("DELETE /cdn/4.0/domains/"+domain+"/dns-records/"+recordID, nil). + Build(t) err := client.DeleteRecord(t.Context(), domain, recordID) require.NoError(t, err) diff --git a/providers/dns/arvancloud/internal/fixtures/create_record-request.json b/providers/dns/arvancloud/internal/fixtures/create_record-request.json new file mode 100644 index 000000000..48a7124f6 --- /dev/null +++ b/providers/dns/arvancloud/internal/fixtures/create_record-request.json @@ -0,0 +1,8 @@ +{ + "type": "txt", + "value": { + "text": "txtxtxt" + }, + "name": "_acme-challenge", + "ttl": 600 +} diff --git a/providers/dns/auroradns/auroradns_test.go b/providers/dns/auroradns/auroradns_test.go index cbd51b830..1619ee586 100644 --- a/providers/dns/auroradns/auroradns_test.go +++ b/providers/dns/auroradns/auroradns_test.go @@ -1,35 +1,32 @@ package auroradns import ( - "fmt" - "io" "net/http" "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester" - "github.com/stretchr/testify/assert" + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/nrdcg/auroradns" "github.com/stretchr/testify/require" ) var envTest = tester.NewEnvTest(EnvAPIKey, EnvSecret) -func setupTest(t *testing.T) (*DNSProvider, *http.ServeMux) { - t.Helper() +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.APIKey = "asdf1234" + config.Secret = "key" + config.BaseURL = server.URL - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - config := NewDefaultConfig() - config.APIKey = "asdf1234" - config.Secret = "key" - config.BaseURL = server.URL - - provider, err := NewDNSProviderConfig(config) - require.NoError(t, err) - - return provider, mux + return NewDNSProviderConfig(config) + }, + servermock.CheckHeader(). + WithContentType("application/json"). + WithRegexp("Authorization", `AuroraDNSv1 .+`). + WithRegexp("X-Auroradns-Date", `[0-9TZ]+`)) } func TestNewDNSProvider(t *testing.T) { @@ -145,72 +142,47 @@ func TestNewDNSProviderConfig(t *testing.T) { } func TestDNSProvider_Present(t *testing.T) { - provider, mux := setupTest(t) - - mux.HandleFunc("/zones", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodGet, r.Method, "method") - - w.WriteHeader(http.StatusCreated) - fmt.Fprintf(w, `[{ - "id": "c56a4180-65aa-42ec-a945-5fd21dec0538", - "name": "example.com" - }]`) - }) - - mux.HandleFunc("/zones/c56a4180-65aa-42ec-a945-5fd21dec0538/records", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodPost, r.Method) - assert.Equal(t, "application/json", r.Header.Get("Content-Type"), "Content-Type") - - reqBody, err := io.ReadAll(r.Body) - require.NoError(t, err) - assert.JSONEq(t, `{"type":"TXT","name":"_acme-challenge","content":"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI","ttl":300}`, string(reqBody)) - - w.WriteHeader(http.StatusCreated) - fmt.Fprintf(w, `{ - "id": "c56a4180-65aa-42ec-a945-5fd21dec0538", - "type": "TXT", - "name": "_acme-challenge", - "ttl": 300 - }`) - }) + provider := mockBuilder(). + Route("GET /zones", + servermock.JSONEncode([]auroradns.Zone{{ + ID: "c56a4180-65aa-42ec-a945-5fd21dec0538", + Name: "example.com", + }}). + WithStatusCode(http.StatusCreated)). + Route("POST /zones/c56a4180-65aa-42ec-a945-5fd21dec0538/records", + servermock.JSONEncode(auroradns.Record{ + ID: "ec56a4180-65aa-42ec-a945-5fd21dec0538", + RecordType: "TXT", + Name: "_acme-challenge", + TTL: 300, + }). + WithStatusCode(http.StatusCreated)). + Build(t) err := provider.Present("example.com", "", "foobar") require.NoError(t, err, "fail to create TXT record") } func TestDNSProvider_CleanUp(t *testing.T) { - provider, mux := setupTest(t) - - mux.HandleFunc("/zones", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodGet, r.Method) - - w.WriteHeader(http.StatusCreated) - fmt.Fprintf(w, `[{ - "id": "c56a4180-65aa-42ec-a945-5fd21dec0538", - "name": "example.com" - }]`) - }) - - mux.HandleFunc("/zones/c56a4180-65aa-42ec-a945-5fd21dec0538/records", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodPost, r.Method) - - w.WriteHeader(http.StatusCreated) - fmt.Fprintf(w, `{ - "id": "ec56a4180-65aa-42ec-a945-5fd21dec0538", - "type": "TXT", - "name": "_acme-challenge", - "ttl": 300 - }`) - }) - - mux.HandleFunc("/zones/c56a4180-65aa-42ec-a945-5fd21dec0538/records/ec56a4180-65aa-42ec-a945-5fd21dec0538", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodDelete, r.Method) - - assert.Equal(t, "application/json", r.Header.Get("Content-Type"), "Content-Type") - - w.WriteHeader(http.StatusCreated) - fmt.Fprintf(w, `{}`) - }) + provider := mockBuilder(). + Route("GET /zones", + servermock.JSONEncode([]auroradns.Zone{{ + ID: "c56a4180-65aa-42ec-a945-5fd21dec0538", + Name: "example.com", + }}). + WithStatusCode(http.StatusCreated)). + Route("POST /zones/c56a4180-65aa-42ec-a945-5fd21dec0538/records", + servermock.JSONEncode(auroradns.Record{ + ID: "ec56a4180-65aa-42ec-a945-5fd21dec0538", + RecordType: "TXT", + Name: "_acme-challenge", + TTL: 300, + }). + WithStatusCode(http.StatusCreated)). + Route("DELETE /zones/c56a4180-65aa-42ec-a945-5fd21dec0538/records/ec56a4180-65aa-42ec-a945-5fd21dec0538", + servermock.RawStringResponse("{}"). + WithStatusCode(http.StatusCreated)). + Build(t) err := provider.Present("example.com", "", "foobar") require.NoError(t, err, "fail to create TXT record") diff --git a/providers/dns/autodns/internal/client_test.go b/providers/dns/autodns/internal/client_test.go index d656e0ae9..5eb6486ea 100644 --- a/providers/dns/autodns/internal/client_test.go +++ b/providers/dns/autodns/internal/client_test.go @@ -1,68 +1,37 @@ package internal import ( - "fmt" - "io" - "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, method, pattern string, status int, file string) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("user", "secret", 123) + client.HTTPClient = server.Client() + client.BaseURL, _ = url.Parse(server.URL) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusBadRequest) - return - } - - apiUser, apiKey, ok := req.BasicAuth() - if apiUser != "user" || apiKey != "secret" || !ok { - http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return - } - - if file == "" { - rw.WriteHeader(status) - return - } - - open, err := os.Open(filepath.Join("fixtures", file)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = open.Close() }() - - rw.WriteHeader(status) - _, err = io.Copy(rw, open) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - client := NewClient("user", "secret", 123) - client.HTTPClient = server.Client() - client.BaseURL, _ = url.Parse(server.URL) - - return client + return client, nil + }, + servermock.CheckHeader(). + WithBasicAuth("user", "secret"). + WithJSONHeaders()) } func TestClient_AddTxtRecords(t *testing.T) { - client := setupTest(t, http.MethodPost, "/zone/example.com/_stream", http.StatusOK, "add-record.json") + client := mockBuilder(). + Route("POST /zone/example.com/_stream", + servermock.ResponseFromFixture("add_record.json"), + servermock.CheckRequestJSONBodyFromFile("add_record-request.json"), + servermock.CheckHeader(). + With("X-Domainrobot-Context", "123")). + Build(t) records := []*ResourceRecord{{}} @@ -86,7 +55,13 @@ func TestClient_AddTxtRecords(t *testing.T) { } func TestClient_RemoveTXTRecords(t *testing.T) { - client := setupTest(t, http.MethodPost, "/zone/example.com/_stream", http.StatusOK, "add-record.json") + client := mockBuilder(). + Route("POST /zone/example.com/_stream", + servermock.ResponseFromFixture("remove_record.json"), + servermock.CheckRequestJSONBodyFromFile("remove_record-request.json"), + servermock.CheckHeader(). + With("X-Domainrobot-Context", "123")). + Build(t) records := []*ResourceRecord{{}} diff --git a/providers/dns/autodns/internal/fixtures/add_record-request.json b/providers/dns/autodns/internal/fixtures/add_record-request.json new file mode 100644 index 000000000..b798b4fbd --- /dev/null +++ b/providers/dns/autodns/internal/fixtures/add_record-request.json @@ -0,0 +1,11 @@ +{ + "adds": [ + { + "name": "", + "ttl": 0, + "type": "", + "value": "" + } + ], + "rems": null +} diff --git a/providers/dns/autodns/internal/fixtures/add-record.json b/providers/dns/autodns/internal/fixtures/add_record.json similarity index 100% rename from providers/dns/autodns/internal/fixtures/add-record.json rename to providers/dns/autodns/internal/fixtures/add_record.json diff --git a/providers/dns/autodns/internal/fixtures/remove_record-request.json b/providers/dns/autodns/internal/fixtures/remove_record-request.json new file mode 100644 index 000000000..0702c7367 --- /dev/null +++ b/providers/dns/autodns/internal/fixtures/remove_record-request.json @@ -0,0 +1,11 @@ +{ + "adds": null, + "rems": [ + { + "name": "", + "ttl": 0, + "type": "", + "value": "" + } + ] +} diff --git a/providers/dns/autodns/internal/fixtures/remove-record.json b/providers/dns/autodns/internal/fixtures/remove_record.json similarity index 100% rename from providers/dns/autodns/internal/fixtures/remove-record.json rename to providers/dns/autodns/internal/fixtures/remove_record.json diff --git a/providers/dns/axelname/internal/client_test.go b/providers/dns/axelname/internal/client_test.go index 0ead4b180..7796f6047 100644 --- a/providers/dns/axelname/internal/client_test.go +++ b/providers/dns/axelname/internal/client_test.go @@ -1,58 +1,37 @@ package internal import ( - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, pattern string, status int, filename string) *Client { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) { - if filename == "" { - rw.WriteHeader(status) - return - } - - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = file.Close() }() - - rw.WriteHeader(status) - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - +func setupClient(server *httptest.Server) (*Client, error) { client, err := NewClient("user", "secret") - require.NoError(t, err) + if err != nil { + return nil, err + } client.HTTPClient = server.Client() client.baseURL, _ = url.Parse(server.URL) - return client + return client, nil } func TestClient_ListRecords(t *testing.T) { - client := setupTest(t, "GET /dns_list", http.StatusOK, "dns_list.json") + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /dns_list", + servermock.ResponseFromFixture("dns_list.json"), + servermock.CheckQueryParameter().Strict(). + With("domain", "example.com"). + With("nichdl", "user"). + With("token", "secret")). + Build(t) records, err := client.ListRecords(t.Context(), "example.com") require.NoError(t, err) @@ -68,14 +47,26 @@ func TestClient_ListRecords(t *testing.T) { } func TestClient_ListRecords_error(t *testing.T) { - client := setupTest(t, "GET /dns_list", http.StatusNotFound, "dns_list_error.json") + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /dns_list", + servermock.ResponseFromFixture("dns_list_error.json"). + WithStatusCode(http.StatusNotFound)). + Build(t) _, err := client.ListRecords(t.Context(), "example.com") require.EqualError(t, err, "error: Domain not found (1)") } func TestClient_DeleteRecord(t *testing.T) { - client := setupTest(t, "GET /dns_delete", http.StatusOK, "dns_delete.json") + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /dns_delete", + servermock.ResponseFromFixture("dns_delete.json"), + servermock.CheckQueryParameter().Strict(). + With("id", "74749"). + With("domain", "example.com"). + With("nichdl", "user"). + With("token", "secret")). + Build(t) record := Record{ID: "74749"} @@ -84,7 +75,11 @@ func TestClient_DeleteRecord(t *testing.T) { } func TestClient_DeleteRecord_error(t *testing.T) { - client := setupTest(t, "GET /dns_delete", http.StatusNotFound, "dns_delete_error.json") + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /dns_delete", + servermock.ResponseFromFixture("dns_delete_error.json"). + WithStatusCode(http.StatusNotFound)). + Build(t) record := Record{ID: "74749"} @@ -93,7 +88,15 @@ func TestClient_DeleteRecord_error(t *testing.T) { } func TestClient_AddRecord(t *testing.T) { - client := setupTest(t, "GET /dns_add", http.StatusOK, "dns_add.json") + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /dns_add", + servermock.ResponseFromFixture("dns_add.json"), + servermock.CheckQueryParameter().Strict(). + With("id", "74749"). + With("domain", "example.com"). + With("nichdl", "user"). + With("token", "secret")). + Build(t) record := Record{ID: "74749"} @@ -102,7 +105,11 @@ func TestClient_AddRecord(t *testing.T) { } func TestClient_AddRecord_error(t *testing.T) { - client := setupTest(t, "GET /dns_add", http.StatusNotFound, "dns_add_error.json") + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /dns_add", + servermock.ResponseFromFixture("dns_add_error.json"). + WithStatusCode(http.StatusNotFound)). + Build(t) record := Record{ID: "74749"} diff --git a/providers/dns/azion/azion_test.go b/providers/dns/azion/azion_test.go index de25e7c69..b3b553114 100644 --- a/providers/dns/azion/azion_test.go +++ b/providers/dns/azion/azion_test.go @@ -2,15 +2,12 @@ package azion import ( "context" - "io" - "net/http" "net/http/httptest" - "os" - "path/filepath" "testing" "github.com/aziontech/azionapi-go-sdk/idns" "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -123,8 +120,9 @@ func TestLiveCleanUp(t *testing.T) { } func TestDNSProvider_findZone(t *testing.T) { - provider, mux := setupTest(t) - mux.HandleFunc("GET /intelligent_dns", writeFixtureHandler("zones.json")) + provider := mockBuilder(). + Route("GET /intelligent_dns", servermock.ResponseFromFixture("zones.json")). + Build(t) testCases := []struct { desc string @@ -198,8 +196,9 @@ func TestDNSProvider_findZone_error(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - provider, mux := setupTest(t) - mux.HandleFunc("GET /intelligent_dns", writeFixtureHandler(test.response)) + provider := mockBuilder(). + Route("GET /intelligent_dns", servermock.ResponseFromFixture(test.response)). + Build(t) zone, err := provider.findZone(context.Background(), test.fqdn) require.EqualError(t, err, test.expected) @@ -209,41 +208,25 @@ func TestDNSProvider_findZone_error(t *testing.T) { } } -func setupTest(t *testing.T) (*DNSProvider, *http.ServeMux) { - t.Helper() +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.PersonalToken = "secret" - mux := http.NewServeMux() - server := httptest.NewServer(mux) + provider, err := NewDNSProviderConfig(config) + if err != nil { + return nil, err + } - config := NewDefaultConfig() - config.PersonalToken = "secret" + clientConfig := provider.client.GetConfig() + clientConfig.HTTPClient = server.Client() + clientConfig.Servers = idns.ServerConfigurations{{ + URL: server.URL, + Description: "Production", + }} - provider, err := NewDNSProviderConfig(config) - require.NoError(t, err) - - clientConfig := provider.client.GetConfig() - clientConfig.HTTPClient = server.Client() - clientConfig.Servers = idns.ServerConfigurations{ - { - URL: server.URL, - Description: "Production", + return provider, nil }, - } - - return provider, mux -} - -func writeFixtureHandler(filename string) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - rw.Header().Set("Content-Type", "application/json") - - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, _ = io.Copy(rw, file) - } + ) } diff --git a/providers/dns/bluecat/internal/client_test.go b/providers/dns/bluecat/internal/client_test.go index c06ae1b8b..9d79f46b3 100644 --- a/providers/dns/bluecat/internal/client_test.go +++ b/providers/dns/bluecat/internal/client_test.go @@ -6,33 +6,37 @@ import ( "net/http/httptest" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestClient_LookupParentZoneID(t *testing.T) { - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - +func setupClient(server *httptest.Server) (*Client, error) { client := NewClient(server.URL, "user", "secret") client.HTTPClient = server.Client() - mux.HandleFunc("/Services/REST/v1/getEntityByName", func(rw http.ResponseWriter, req *http.Request) { - query := req.URL.Query() + return client, nil +} - if query.Get("name") == "com" { - _ = json.NewEncoder(rw).Encode(EntityResponse{ - ID: 2, - Name: "com", - Type: ZoneType, - Properties: "test", - }) - return - } +func TestClient_LookupParentZoneID(t *testing.T) { + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /Services/REST/v1/getEntityByName", + http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + query := req.URL.Query() - http.Error(rw, "{}", http.StatusOK) - }) + if query.Get("name") == "com" { + _ = json.NewEncoder(rw).Encode(EntityResponse{ + ID: 2, + Name: "com", + Type: ZoneType, + Properties: "test", + }) + return + } + + _, _ = rw.Write([]byte(`{}`)) + })). + Build(t) parentID, name, err := client.LookupParentZoneID(t.Context(), 2, "foo.example.com") require.NoError(t, err) diff --git a/providers/dns/bluecat/internal/identity_test.go b/providers/dns/bluecat/internal/identity_test.go index 3d9e00c0e..9ad4c18e6 100644 --- a/providers/dns/bluecat/internal/identity_test.go +++ b/providers/dns/bluecat/internal/identity_test.go @@ -1,11 +1,9 @@ package internal import ( - "fmt" - "net/http" - "net/http/httptest" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -13,39 +11,16 @@ import ( const fakeToken = "BAMAuthToken: dQfuRMTUxNjc3MjcyNDg1ODppcGFybXM=" func TestClient_CreateAuthenticatedContext(t *testing.T) { - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := NewClient(server.URL, "user", "secret") - client.HTTPClient = server.Client() - - mux.HandleFunc("/Services/REST/v1/login", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - query := req.URL.Query() - if query.Get("username") != "user" { - http.Error(rw, fmt.Sprintf("invalid username %s", query.Get("username")), http.StatusUnauthorized) - return - } - - if query.Get("password") != "secret" { - http.Error(rw, fmt.Sprintf("invalid password %s", query.Get("password")), http.StatusUnauthorized) - return - } - - _, _ = fmt.Fprint(rw, fakeToken) - }) - mux.HandleFunc("/Services/REST/v1/delete", func(rw http.ResponseWriter, req *http.Request) { - authorization := req.Header.Get(authorizationHeader) - if authorization != fakeToken { - http.Error(rw, fmt.Sprintf("invalid credential: %s", authorization), http.StatusUnauthorized) - return - } - }) + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /Services/REST/v1/login", + servermock.RawStringResponse(fakeToken), + servermock.CheckQueryParameter(). + With("username", "user"). + With("password", "secret")). + Route("DELETE /Services/REST/v1/delete", nil, + servermock.CheckHeader(). + WithAuthorization(fakeToken)). + Build(t) ctx, err := client.CreateAuthenticatedContext(t.Context()) require.NoError(t, err) diff --git a/providers/dns/bookmyname/internal/client_test.go b/providers/dns/bookmyname/internal/client_test.go index 26e5f7227..900d62fef 100644 --- a/providers/dns/bookmyname/internal/client_test.go +++ b/providers/dns/bookmyname/internal/client_test.go @@ -1,58 +1,42 @@ package internal import ( - "fmt" - "io" - "net/http" "net/http/httptest" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, filename string) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient("user", "secret") + if err != nil { + return nil, err + } - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) + client.HTTPClient = server.Client() + client.baseURL = server.URL - mux.HandleFunc("GET /", func(rw http.ResponseWriter, req *http.Request) { - username, password, ok := req.BasicAuth() - if username != "user" || password != "secret" || !ok { - http.Error(rw, fmt.Sprintf("auth: user %s, password %s, malformed", username, password), http.StatusOK) - return - } - - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = file.Close() }() - - rw.WriteHeader(http.StatusOK) - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - client, err := NewClient("user", "secret") - require.NoError(t, err) - - client.HTTPClient = server.Client() - client.baseURL = server.URL - - return client + return client, nil + }, + servermock.CheckHeader(). + WithBasicAuth("user", "secret")) } func TestClient_AddRecord(t *testing.T) { - client := setupTest(t, "add_success.txt") + client := mockBuilder(). + Route("GET /", + servermock.ResponseFromFixture("add_success.txt"), + servermock.CheckQueryParameter().Strict(). + With("do", "add"). + With("hostname", "_acme-challenge.sub.example.com."). + With("type", "txt"). + With("value", "test"). + With("ttl", "300"), + ). + Build(t) record := Record{ Hostname: "_acme-challenge.sub.example.com.", @@ -66,7 +50,12 @@ func TestClient_AddRecord(t *testing.T) { } func TestClient_AddRecord_error(t *testing.T) { - client := setupTest(t, "error.txt") + client := mockBuilder(). + Route("GET /", + servermock.ResponseFromFixture("error.txt"), + servermock.CheckQueryParameter(). + With("do", "add")). + Build(t) record := Record{ Hostname: "_acme-challenge.sub.example.com.", @@ -82,7 +71,17 @@ func TestClient_AddRecord_error(t *testing.T) { } func TestClient_RemoveRecord(t *testing.T) { - client := setupTest(t, "remove_success.txt") + client := mockBuilder(). + Route("GET /", + servermock.ResponseFromFixture("remove_success.txt"), + servermock.CheckQueryParameter().Strict(). + With("do", "remove"). + With("hostname", "_acme-challenge.sub.example.com."). + With("type", "txt"). + With("value", "test"). + With("ttl", "300"), + ). + Build(t) record := Record{ Hostname: "_acme-challenge.sub.example.com.", @@ -96,7 +95,12 @@ func TestClient_RemoveRecord(t *testing.T) { } func TestClient_RemoveRecord_error(t *testing.T) { - client := setupTest(t, "error.txt") + client := mockBuilder(). + Route("GET /", + servermock.ResponseFromFixture("error.txt"), + servermock.CheckQueryParameter(). + With("do", "remove")). + Build(t) record := Record{ Hostname: "_acme-challenge.sub.example.com.", diff --git a/providers/dns/brandit/internal/client_test.go b/providers/dns/brandit/internal/client_test.go index 0e79e5799..cb779ef68 100644 --- a/providers/dns/brandit/internal/client_test.go +++ b/providers/dns/brandit/internal/client_test.go @@ -1,49 +1,42 @@ package internal import ( - "io" - "net/http" "net/http/httptest" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, filename string) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient("user", "secret") + if err != nil { + return nil, err + } - server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } + client.HTTPClient = server.Client() + client.baseURL = server.URL - defer func() { _ = file.Close() }() - - rw.WriteHeader(http.StatusOK) - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - })) - t.Cleanup(server.Close) - - client, err := NewClient("test_user", "apiKey") - require.NoError(t, err) - - client.HTTPClient = server.Client() - client.baseURL = server.URL - - return client + return client, nil + }, + servermock.CheckHeader(). + WithContentTypeFromURLEncoded()) } func TestClient_StatusDomain(t *testing.T) { - client := setupTest(t, "status-domain.json") + client := mockBuilder(). + Route("POST /", servermock.ResponseFromFixture("status-domain.json"), + servermock.CheckForm().Strict(). + WithRegexp("signature", "[a-z0-9]+"). + WithRegexp("timestamp", `\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z`). + With("command", "statusDomain"). + With("user", "user"). + With("domain", "example.com"), + ). + Build(t) domain, err := client.StatusDomain(t.Context(), "example.com") require.NoError(t, err) @@ -79,14 +72,26 @@ func TestClient_StatusDomain(t *testing.T) { } func TestClient_StatusDomain_error(t *testing.T) { - client := setupTest(t, "error.json") + client := mockBuilder(). + Route("POST /", servermock.ResponseFromFixture("error.json")). + Build(t) _, err := client.StatusDomain(t.Context(), "example.com") require.ErrorIs(t, err, APIError{Code: 402, Status: "error", Message: "Invalid user."}) } func TestClient_ListRecords(t *testing.T) { - client := setupTest(t, "list-records.json") + client := mockBuilder(). + Route("POST /", servermock.ResponseFromFixture("list-records.json"), + servermock.CheckForm().Strict(). + WithRegexp("signature", "[a-z0-9]+"). + WithRegexp("timestamp", `\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z`). + With("account", "example"). + With("command", "listDNSRR"). + With("user", "user"). + With("dnszone", "example.com"), + ). + Build(t) resp, err := client.ListRecords(t.Context(), "example", "example.com") require.NoError(t, err) @@ -105,14 +110,28 @@ func TestClient_ListRecords(t *testing.T) { } func TestClient_ListRecords_error(t *testing.T) { - client := setupTest(t, "error.json") + client := mockBuilder(). + Route("POST /", servermock.ResponseFromFixture("error.json")). + Build(t) _, err := client.ListRecords(t.Context(), "example", "example.com") require.ErrorIs(t, err, APIError{Code: 402, Status: "error", Message: "Invalid user."}) } func TestClient_AddRecord(t *testing.T) { - client := setupTest(t, "add-record.json") + client := mockBuilder(). + Route("POST /", servermock.ResponseFromFixture("add-record.json"), + servermock.CheckForm().Strict(). + WithRegexp("signature", "[a-z0-9]+"). + WithRegexp("timestamp", `\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z`). + With("account", "test"). + With("command", "addDNSRR"). + With("key", "2565"). + With("user", "user"). + With("rrdata", "example.com 600 IN TXT txttxttxt"). + With("dnszone", "example.com"), + ). + Build(t) testRecord := Record{ ID: 2565, @@ -139,7 +158,9 @@ func TestClient_AddRecord(t *testing.T) { } func TestClient_AddRecord_error(t *testing.T) { - client := setupTest(t, "error.json") + client := mockBuilder(). + Route("POST /", servermock.ResponseFromFixture("error.json")). + Build(t) testRecord := Record{ ID: 2565, @@ -154,14 +175,28 @@ func TestClient_AddRecord_error(t *testing.T) { } func TestClient_DeleteRecord(t *testing.T) { - client := setupTest(t, "delete-record.json") + client := mockBuilder(). + Route("POST /", servermock.ResponseFromFixture("delete-record.json"), + servermock.CheckForm().Strict(). + WithRegexp("signature", "[a-z0-9]+"). + WithRegexp("timestamp", `\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z`). + With("account", "test"). + With("command", "deleteDNSRR"). + With("key", "2374"). + With("user", "user"). + With("rrdata", "example.com 600 IN TXT txttxttxt"). + With("dnszone", "example.com"), + ). + Build(t) err := client.DeleteRecord(t.Context(), "example.com", "test", "example.com 600 IN TXT txttxttxt", "2374") require.NoError(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { - client := setupTest(t, "error.json") + client := mockBuilder(). + Route("POST /", servermock.ResponseFromFixture("error.json")). + Build(t) err := client.DeleteRecord(t.Context(), "example.com", "test", "example.com 600 IN TXT txttxttxt", "2374") require.ErrorIs(t, err, APIError{Code: 402, Status: "error", Message: "Invalid user."}) diff --git a/providers/dns/checkdomain/internal/client_test.go b/providers/dns/checkdomain/internal/client_test.go index 60d55ee5e..31d419a5f 100644 --- a/providers/dns/checkdomain/internal/client_test.go +++ b/providers/dns/checkdomain/internal/client_test.go @@ -1,70 +1,38 @@ package internal import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" "net/http/httptest" "net/url" - "reflect" "testing" "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient(OAuthStaticAccessToken(server.Client(), "secret")) + client.BaseURL, _ = url.Parse(server.URL) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := NewClient(OAuthStaticAccessToken(server.Client(), "secret")) - client.BaseURL, _ = url.Parse(server.URL) - - return client, mux -} - -func checkAuthorizationHeader(req *http.Request) error { - val := req.Header.Get("Authorization") - if val != "Bearer secret" { - return fmt.Errorf("invalid header value, got: %s want %s", val, "Bearer secret") - } - return nil + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("Bearer secret")) } func TestClient_GetDomainIDByName(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/v1/domains", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - err := checkAuthorizationHeader(req) - if err != nil { - http.Error(rw, err.Error(), http.StatusUnauthorized) - return - } - - domainList := DomainListingResponse{ - Embedded: EmbeddedDomainList{Domains: []*Domain{ - {ID: 1, Name: "test.com"}, - {ID: 2, Name: "test.org"}, - }}, - } - - err = json.NewEncoder(rw).Encode(domainList) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder(). + Route("GET /v1/domains", + servermock.JSONEncode(DomainListingResponse{ + Embedded: EmbeddedDomainList{Domains: []*Domain{ + {ID: 1, Name: "test.com"}, + {ID: 2, Name: "test.org"}, + }}, + })). + Build(t) id, err := client.GetDomainIDByName(t.Context(), "test.com") require.NoError(t, err) @@ -73,65 +41,26 @@ func TestClient_GetDomainIDByName(t *testing.T) { } func TestClient_CheckNameservers(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/v1/domains/1/nameservers", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - err := checkAuthorizationHeader(req) - if err != nil { - http.Error(rw, err.Error(), http.StatusUnauthorized) - return - } - - nsResp := NameserverResponse{ - Nameservers: []*Nameserver{ - {Name: ns1}, - {Name: ns2}, - // {Name: "ns.fake.de"}, - }, - } - - err = json.NewEncoder(rw).Encode(nsResp) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder(). + Route("GET /v1/domains/1/nameservers", + servermock.JSONEncode(NameserverResponse{ + Nameservers: []*Nameserver{ + {Name: ns1}, + {Name: ns2}, + // {Name: "ns.fake.de"}, + }, + })). + Build(t) err := client.CheckNameservers(t.Context(), 1) require.NoError(t, err) } func TestClient_CreateRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/v1/domains/1/nameservers/records", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - err := checkAuthorizationHeader(req) - if err != nil { - http.Error(rw, err.Error(), http.StatusUnauthorized) - return - } - - content, err := io.ReadAll(req.Body) - if err != nil { - http.Error(rw, err.Error(), http.StatusBadRequest) - return - } - - if string(bytes.TrimSpace(content)) != `{"name":"test.com","value":"value","ttl":300,"priority":0,"type":"TXT"}` { - http.Error(rw, "invalid request body: "+string(content), http.StatusBadRequest) - return - } - }) + client := mockBuilder(). + Route("POST /v1/domains/1/nameservers/records", nil, + servermock.CheckRequestJSONBodyFromFile("create_record-request.json")). + Build(t) record := &Record{ Name: "test.com", @@ -145,114 +74,44 @@ func TestClient_CreateRecord(t *testing.T) { } func TestClient_DeleteTXTRecord(t *testing.T) { - client, mux := setupTest(t) - domainName := "lego.test" recordValue := "test" - records := []*Record{ - { - Name: "_acme-challenge", - Value: recordValue, - Type: "TXT", - }, - { - Name: "_acme-challenge", - Value: recordValue, - Type: "A", - }, - { - Name: "foobar", - Value: recordValue, - Type: "TXT", - }, - } - - expectedRecords := []*Record{ - { - Name: "_acme-challenge", - Value: recordValue, - Type: "A", - }, - { - Name: "foobar", - Value: recordValue, - Type: "TXT", - }, - } - - mux.HandleFunc("/v1/domains/1", func(rw http.ResponseWriter, req *http.Request) { - err := checkAuthorizationHeader(req) - if err != nil { - http.Error(rw, err.Error(), http.StatusUnauthorized) - return - } - - resp := DomainResponse{ - ID: 1, - Name: domainName, - } - - err = json.NewEncoder(rw).Encode(resp) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - mux.HandleFunc("/v1/domains/1/nameservers", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - nsResp := NameserverResponse{ - Nameservers: []*Nameserver{{Name: ns1}, {Name: ns2}}, - } - - err := json.NewEncoder(rw).Encode(nsResp) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - mux.HandleFunc("/v1/domains/1/nameservers/records", func(rw http.ResponseWriter, req *http.Request) { - switch req.Method { - case http.MethodGet: - resp := RecordListingResponse{ + client := mockBuilder(). + Route("GET /v1/domains/", + servermock.JSONEncode(DomainResponse{ + ID: 1, + Name: domainName, + })). + Route("GET /v1/domains/1/nameservers", + servermock.JSONEncode(NameserverResponse{ + Nameservers: []*Nameserver{{Name: ns1}, {Name: ns2}}, + })). + Route("GET /v1/domains/1/nameservers/records", + servermock.JSONEncode(RecordListingResponse{ Embedded: EmbeddedRecordList{ - Records: records, + Records: []*Record{ + { + Name: "_acme-challenge", + Value: recordValue, + Type: "TXT", + }, + { + Name: "_acme-challenge", + Value: recordValue, + Type: "A", + }, + { + Name: "foobar", + Value: recordValue, + Type: "TXT", + }, + }, }, - } - - err := json.NewEncoder(rw).Encode(resp) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - case http.MethodPut: - var records []*Record - err := json.NewDecoder(req.Body).Decode(&records) - if err != nil { - http.Error(rw, err.Error(), http.StatusBadRequest) - return - } - - if len(records) == 0 { - http.Error(rw, "empty request body", http.StatusBadRequest) - return - } - - if !reflect.DeepEqual(expectedRecords, records) { - http.Error(rw, fmt.Sprintf("invalid records: %v", records), http.StatusBadRequest) - return - } - default: - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - } - }) + })). + Route("PUT /v1/domains/1/nameservers/records", nil, + servermock.CheckRequestJSONBodyFromFile("delete_txt_record-request.json")). + Build(t) info := dns01.GetChallengeInfo(domainName, "abc") err := client.DeleteTXTRecord(t.Context(), 1, info.EffectiveFQDN, recordValue) diff --git a/providers/dns/checkdomain/internal/fixtures/create_record-request.json b/providers/dns/checkdomain/internal/fixtures/create_record-request.json new file mode 100644 index 000000000..af1d50625 --- /dev/null +++ b/providers/dns/checkdomain/internal/fixtures/create_record-request.json @@ -0,0 +1,7 @@ +{ + "name": "test.com", + "value": "value", + "ttl": 300, + "priority": 0, + "type": "TXT" +} diff --git a/providers/dns/checkdomain/internal/fixtures/delete_txt_record-request.json b/providers/dns/checkdomain/internal/fixtures/delete_txt_record-request.json new file mode 100644 index 000000000..67cb2570c --- /dev/null +++ b/providers/dns/checkdomain/internal/fixtures/delete_txt_record-request.json @@ -0,0 +1,16 @@ +[ + { + "name": "_acme-challenge", + "value": "test", + "ttl": 0, + "priority": 0, + "type": "A" + }, + { + "name": "foobar", + "value": "test", + "ttl": 0, + "priority": 0, + "type": "TXT" + } +] diff --git a/providers/dns/clouddns/internal/client_test.go b/providers/dns/clouddns/internal/client_test.go index 2dee0bd0f..a8092933c 100644 --- a/providers/dns/clouddns/internal/client_test.go +++ b/providers/dns/clouddns/internal/client_test.go @@ -1,127 +1,63 @@ package internal import ( - "encoding/json" - "net/http" "net/http/httptest" "net/url" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("clientID", "email@example.com", "secret", 300) + client.HTTPClient = server.Client() + client.apiBaseURL, _ = url.Parse(server.URL + "/api") + client.loginURL, _ = url.Parse(server.URL + "/login") - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := NewClient("clientID", "email@example.com", "secret", 300) - client.HTTPClient = server.Client() - client.apiBaseURL, _ = url.Parse(server.URL + "/api") - client.loginURL, _ = url.Parse(server.URL + "/login") - - return client, mux + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(), + ) } func TestClient_AddRecord(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("POST /api/domain/search", + servermock.ResponseFromFixture("domain_search.json"), + servermock.CheckRequestJSONBodyFromFile("domain_search-request.json")). + Route("POST /api/record-txt", nil, + servermock.CheckRequestJSONBodyFromFile("record_txt-request.json")). + Route("PUT /api/domain/A/publish", nil, + servermock.CheckRequestJSONBodyFromFile("publish-request.json")). + Route("POST /login", + servermock.ResponseFromFixture("login.json"), + servermock.CheckRequestJSONBodyFromFile("login-request.json")). + Build(t) - mux.HandleFunc("/api/domain/search", func(rw http.ResponseWriter, req *http.Request) { - response := SearchResponse{ - Items: []Domain{ - { - ID: "A", - DomainName: "example.com", - }, - }, - } + ctx, err := client.CreateAuthenticatedContext(t.Context()) + require.NoError(t, err) - err := json.NewEncoder(rw).Encode(response) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - mux.HandleFunc("/api/record-txt", func(rw http.ResponseWriter, req *http.Request) {}) - mux.HandleFunc("/api/domain/A/publish", func(rw http.ResponseWriter, req *http.Request) {}) - mux.HandleFunc("/login", func(rw http.ResponseWriter, req *http.Request) { - response := AuthResponse{ - Auth: Auth{ - AccessToken: "at", - RefreshToken: "", - }, - } - - err := json.NewEncoder(rw).Encode(response) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - err := client.AddRecord(t.Context(), "example.com", "_acme-challenge.example.com", "txt") + err = client.AddRecord(ctx, "example.com", "_acme-challenge.example.com", "txt") require.NoError(t, err) } func TestClient_DeleteRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/api/domain/search", func(rw http.ResponseWriter, req *http.Request) { - response := SearchResponse{ - Items: []Domain{ - { - ID: "A", - DomainName: "example.com", - }, - }, - } - - err := json.NewEncoder(rw).Encode(response) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - mux.HandleFunc("/api/domain/A", func(rw http.ResponseWriter, req *http.Request) { - response := DomainInfo{ - ID: "Z", - DomainName: "example.com", - LastDomainRecordList: []Record{ - { - ID: "R01", - DomainID: "A", - Name: "_acme-challenge.example.com", - Value: "txt", - Type: "TXT", - }, - }, - SoaTTL: 300, - } - - err := json.NewEncoder(rw).Encode(response) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - mux.HandleFunc("/api/record/R01", func(rw http.ResponseWriter, req *http.Request) {}) - mux.HandleFunc("/api/domain/A/publish", func(rw http.ResponseWriter, req *http.Request) {}) - mux.HandleFunc("/login", func(rw http.ResponseWriter, req *http.Request) { - response := AuthResponse{ - Auth: Auth{ - AccessToken: "at", - RefreshToken: "", - }, - } - - err := json.NewEncoder(rw).Encode(response) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder(). + Route("POST /api/domain/search", + servermock.ResponseFromFixture("domain_search.json"), + servermock.CheckRequestJSONBodyFromFile("domain_search-request.json")). + Route("GET /api/domain/A", + servermock.ResponseFromFixture("domain-request.json")). + Route("DELETE /api/record/R01", nil). + Route("PUT /api/domain/A/publish", nil, + servermock.CheckRequestJSONBodyFromFile("publish-request.json")). + Route("POST /login", + servermock.ResponseFromFixture("login.json"), + servermock.CheckRequestJSONBodyFromFile("login-request.json")). + Build(t) ctx, err := client.CreateAuthenticatedContext(t.Context()) require.NoError(t, err) diff --git a/providers/dns/clouddns/internal/fixtures/domain-request.json b/providers/dns/clouddns/internal/fixtures/domain-request.json new file mode 100644 index 000000000..00f60b9bd --- /dev/null +++ b/providers/dns/clouddns/internal/fixtures/domain-request.json @@ -0,0 +1,14 @@ +{ + "id": "Z", + "domainName": "example.com", + "lastDomainRecordList": [ + { + "id": "R01", + "domainId": "A", + "name": "_acme-challenge.example.com", + "value": "txt", + "type": "TXT" + } + ], + "soaTtl": 300 +} diff --git a/providers/dns/clouddns/internal/fixtures/domain_search-request.json b/providers/dns/clouddns/internal/fixtures/domain_search-request.json new file mode 100644 index 000000000..89043dc3a --- /dev/null +++ b/providers/dns/clouddns/internal/fixtures/domain_search-request.json @@ -0,0 +1,14 @@ +{ + "search": [ + { + "name": "clientId", + "operator": "eq", + "value": "clientID" + }, + { + "name": "domainName", + "operator": "eq", + "value": "example.com" + } + ] +} diff --git a/providers/dns/clouddns/internal/fixtures/domain_search.json b/providers/dns/clouddns/internal/fixtures/domain_search.json new file mode 100644 index 000000000..4ee454732 --- /dev/null +++ b/providers/dns/clouddns/internal/fixtures/domain_search.json @@ -0,0 +1,8 @@ +{ + "items": [ + { + "id": "A", + "domainName": "example.com" + } + ] +} diff --git a/providers/dns/clouddns/internal/fixtures/login-request.json b/providers/dns/clouddns/internal/fixtures/login-request.json new file mode 100644 index 000000000..132577e6b --- /dev/null +++ b/providers/dns/clouddns/internal/fixtures/login-request.json @@ -0,0 +1,4 @@ +{ + "email": "email@example.com", + "password": "secret" +} diff --git a/providers/dns/clouddns/internal/fixtures/login.json b/providers/dns/clouddns/internal/fixtures/login.json new file mode 100644 index 000000000..e72ffb19b --- /dev/null +++ b/providers/dns/clouddns/internal/fixtures/login.json @@ -0,0 +1,5 @@ +{ + "auth": { + "accessToken": "at" + } +} diff --git a/providers/dns/clouddns/internal/fixtures/publish-request.json b/providers/dns/clouddns/internal/fixtures/publish-request.json new file mode 100644 index 000000000..383e26958 --- /dev/null +++ b/providers/dns/clouddns/internal/fixtures/publish-request.json @@ -0,0 +1,3 @@ +{ + "soaTtl": 300 +} diff --git a/providers/dns/clouddns/internal/fixtures/record_txt-request.json b/providers/dns/clouddns/internal/fixtures/record_txt-request.json new file mode 100644 index 000000000..cbc2a32a0 --- /dev/null +++ b/providers/dns/clouddns/internal/fixtures/record_txt-request.json @@ -0,0 +1,6 @@ +{ + "domainId": "A", + "name": "_acme-challenge.example.com", + "value": "txt", + "type": "TXT" +} diff --git a/providers/dns/clouddns/internal/identity_test.go b/providers/dns/clouddns/internal/identity_test.go index a3f3f55ea..df5e20eb8 100644 --- a/providers/dns/clouddns/internal/identity_test.go +++ b/providers/dns/clouddns/internal/identity_test.go @@ -1,38 +1,20 @@ package internal import ( - "encoding/json" - "net/http" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestClient_CreateAuthenticatedContext(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/login", func(rw http.ResponseWriter, req *http.Request) { - response := AuthResponse{ - Auth: Auth{ - AccessToken: "at", - RefreshToken: "", - }, - } - - err := json.NewEncoder(rw).Encode(response) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - mux.HandleFunc("/api/record/xxx", func(rw http.ResponseWriter, req *http.Request) { - authorization := req.Header.Get(authorizationHeader) - if authorization != "Bearer at" { - http.Error(rw, "invalid credential: "+authorization, http.StatusUnauthorized) - return - } - }) + client := mockBuilder(). + Route("POST /login", + servermock.ResponseFromFixture("login.json"), + servermock.CheckRequestJSONBodyFromFile("login-request.json")). + Route("DELETE /api/record/xxx", nil). + Build(t) ctx, err := client.CreateAuthenticatedContext(t.Context()) require.NoError(t, err) diff --git a/providers/dns/cloudns/internal/client_test.go b/providers/dns/cloudns/internal/client_test.go index e5d10b089..dbfa32aee 100644 --- a/providers/dns/cloudns/internal/client_test.go +++ b/providers/dns/cloudns/internal/client_test.go @@ -1,43 +1,25 @@ package internal import ( - "fmt" - "net/http" "net/http/httptest" "net/url" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, subAuthID string, handler http.HandlerFunc) *Client { - t.Helper() - - server := httptest.NewServer(handler) - t.Cleanup(server.Close) - - client, err := NewClient("myAuthID", subAuthID, "myAuthPassword") - require.NoError(t, err) - - client.BaseURL, _ = url.Parse(server.URL) - client.HTTPClient = server.Client() - - return client -} - -func handlerMock(method string, jsonData []byte) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, "Incorrect method used", http.StatusBadRequest) - return - } - - _, err := rw.Write(jsonData) +func setupClient(subAuthID string) func(server *httptest.Server) (*Client, error) { + return func(server *httptest.Server) (*Client, error) { + client, err := NewClient("myAuthID", subAuthID, "myAuthPassword") if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return + return nil, err } + + client.BaseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() + return client, nil } } @@ -131,7 +113,15 @@ func TestClient_GetZone(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - client := setupTest(t, "", handlerMock(http.MethodGet, []byte(test.apiResponse))) + client := servermock.NewBuilder[*Client](setupClient("")). + Route("GET /get-zone-info.json", + servermock.RawStringResponse(test.apiResponse), + servermock.CheckQueryParameter().Strict(). + With("auth-id", "myAuthID"). + With("auth-password", "myAuthPassword"). + With("domain-name", "foo.com"), + ). + Build(t) zone, err := client.GetZone(t.Context(), test.authFQDN) @@ -238,7 +228,17 @@ func TestClient_FindTxtRecord(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - client := setupTest(t, "", handlerMock(http.MethodGet, []byte(test.apiResponse))) + client := servermock.NewBuilder[*Client](setupClient("")). + Route("GET /records.json", + servermock.RawStringResponse(test.apiResponse), + servermock.CheckQueryParameter().Strict(). + With("auth-id", "myAuthID"). + With("auth-password", "myAuthPassword"). + With("type", "TXT"). + With("host", "_acme-challenge"). + With("domain-name", test.zoneName), + ). + Build(t) txtRecord, err := client.FindTxtRecord(t.Context(), test.zoneName, test.authFQDN) @@ -347,7 +347,17 @@ func TestClient_ListTxtRecord(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - client := setupTest(t, "", handlerMock(http.MethodGet, []byte(test.apiResponse))) + client := servermock.NewBuilder[*Client](setupClient("")). + Route("GET /records.json", + servermock.RawStringResponse(test.apiResponse), + servermock.CheckQueryParameter().Strict(). + With("auth-id", "myAuthID"). + With("auth-password", "myAuthPassword"). + With("type", "TXT"). + With("host", "_acme-challenge"). + With("domain-name", test.zoneName), + ). + Build(t) txtRecords, err := client.ListTxtRecords(t.Context(), test.zoneName, test.authFQDN) @@ -363,7 +373,7 @@ func TestClient_ListTxtRecord(t *testing.T) { func TestClient_AddTxtRecord(t *testing.T) { type expected struct { - query string + query url.Values errorMsg string } @@ -387,7 +397,15 @@ func TestClient_AddTxtRecord(t *testing.T) { ttl: 60, apiResponse: `{"status":"Success","statusDescription":"The record was added successfully."}`, expected: expected{ - query: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=example.com&host=_acme-challenge.foo&record=txtTXTtxtTXTtxtTXTtxtTXT&record-type=TXT&ttl=60`, + query: url.Values{ + "auth-id": {"myAuthID"}, + "auth-password": {"myAuthPassword"}, + "domain-name": {"example.com"}, + "host": {"_acme-challenge.foo"}, + "record": {"txtTXTtxtTXTtxtTXTtxtTXT"}, + "record-type": {"TXT"}, + "ttl": {"60"}, + }, }, }, { @@ -399,7 +417,15 @@ func TestClient_AddTxtRecord(t *testing.T) { ttl: 60, apiResponse: `{"status":"Success","statusDescription":"The record was added successfully."}`, expected: expected{ - query: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=example.com&host=_acme-challenge&record=TXTtxtTXTtxtTXTtxtTXTtxt&record-type=TXT&ttl=60`, + query: url.Values{ + "auth-id": {"myAuthID"}, + "auth-password": {"myAuthPassword"}, + "domain-name": {"example.com"}, + "host": {"_acme-challenge"}, + "record": {"TXTtxtTXTtxtTXTtxtTXTtxt"}, + "record-type": {"TXT"}, + "ttl": {"60"}, + }, }, }, { @@ -411,7 +437,15 @@ func TestClient_AddTxtRecord(t *testing.T) { ttl: 60, apiResponse: `{"status":"Success","statusDescription":"The record was added successfully."}`, expected: expected{ - query: `auth-password=myAuthPassword&domain-name=example.com&host=_acme-challenge&record=TXTtxtTXTtxtTXTtxtTXTtxt&record-type=TXT&sub-auth-id=mySubAuthID&ttl=60`, + query: url.Values{ + "auth-password": {"myAuthPassword"}, + "domain-name": {"example.com"}, + "host": {"_acme-challenge"}, + "record": {"TXTtxtTXTtxtTXTtxtTXTtxt"}, + "record-type": {"TXT"}, + "sub-auth-id": {"mySubAuthID"}, + "ttl": {"60"}, + }, }, }, { @@ -423,7 +457,15 @@ func TestClient_AddTxtRecord(t *testing.T) { ttl: 120, apiResponse: `{"status":"Failed","statusDescription":"Invalid TTL. Choose from the list of the values we support."}`, expected: expected{ - query: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=example.com&host=_acme-challenge&record=TXTtxtTXTtxtTXTtxtTXTtxt&record-type=TXT&ttl=300`, + query: url.Values{ + "auth-id": {"myAuthID"}, + "auth-password": {"myAuthPassword"}, + "domain-name": {"example.com"}, + "host": {"_acme-challenge"}, + "record": {"TXTtxtTXTtxtTXTtxtTXTtxt"}, + "record-type": {"TXT"}, + "ttl": {"300"}, + }, errorMsg: "failed to add TXT record: Failed Invalid TTL. Choose from the list of the values we support.", }, }, @@ -436,7 +478,15 @@ func TestClient_AddTxtRecord(t *testing.T) { ttl: 120, apiResponse: `[{}]`, expected: expected{ - query: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=example.com&host=_acme-challenge&record=TXTtxtTXTtxtTXTtxtTXTtxt&record-type=TXT&ttl=300`, + query: url.Values{ + "auth-id": {"myAuthID"}, + "auth-password": {"myAuthPassword"}, + "domain-name": {"example.com"}, + "host": {"_acme-challenge"}, + "record": {"TXTtxtTXTtxtTXTtxtTXTtxt"}, + "record-type": {"TXT"}, + "ttl": {"300"}, + }, errorMsg: "unable to unmarshal response: [status code: 200] body: [{}] error: json: cannot unmarshal array into Go value of type internal.apiResponse", }, }, @@ -444,15 +494,13 @@ func TestClient_AddTxtRecord(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - client := setupTest(t, test.subAuthID, func(rw http.ResponseWriter, req *http.Request) { - if test.expected.query != req.URL.RawQuery { - msg := fmt.Sprintf("got: %s, want: %s", test.expected.query, req.URL.RawQuery) - http.Error(rw, msg, http.StatusBadRequest) - return - } - - handlerMock(http.MethodPost, []byte(test.apiResponse))(rw, req) - }) + client := servermock.NewBuilder[*Client](setupClient(test.subAuthID)). + Route("POST /add-record.json", + servermock.RawStringResponse(test.apiResponse), + servermock.CheckQueryParameter().Strict(). + WithValues(test.expected.query), + ). + Build(t) err := client.AddTxtRecord(t.Context(), test.zoneName, test.authFQDN, test.value, test.ttl) @@ -467,7 +515,7 @@ func TestClient_AddTxtRecord(t *testing.T) { func TestClient_RemoveTxtRecord(t *testing.T) { type expected struct { - query string + query url.Values errorMsg string } @@ -484,7 +532,12 @@ func TestClient_RemoveTxtRecord(t *testing.T) { zoneName: "foo.com", apiResponse: `{ "status": "Success", "statusDescription": "The record was deleted successfully." }`, expected: expected{ - query: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=foo.com&record-id=5769228`, + query: url.Values{ + "auth-id": {"myAuthID"}, + "auth-password": {"myAuthPassword"}, + "domain-name": {"foo.com"}, + "record-id": {"5769228"}, + }, }, }, { @@ -493,7 +546,12 @@ func TestClient_RemoveTxtRecord(t *testing.T) { zoneName: "foo.com", apiResponse: `{ "status": "Failed", "statusDescription": "Invalid record-id param." }`, expected: expected{ - query: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=foo.com&record-id=5769000`, + query: url.Values{ + "auth-id": {"myAuthID"}, + "auth-password": {"myAuthPassword"}, + "domain-name": {"foo.com"}, + "record-id": {"5769000"}, + }, errorMsg: "failed to remove TXT record: Failed Invalid record-id param.", }, }, @@ -503,7 +561,12 @@ func TestClient_RemoveTxtRecord(t *testing.T) { zoneName: "foo-plus.com", apiResponse: `[{}]`, expected: expected{ - query: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=foo-plus.com&record-id=44`, + query: url.Values{ + "auth-id": {"myAuthID"}, + "auth-password": {"myAuthPassword"}, + "domain-name": {"foo-plus.com"}, + "record-id": {"44"}, + }, errorMsg: "unable to unmarshal response: [status code: 200] body: [{}] error: json: cannot unmarshal array into Go value of type internal.apiResponse", }, }, @@ -511,23 +574,15 @@ func TestClient_RemoveTxtRecord(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { - if test.expected.query != req.URL.RawQuery { - msg := fmt.Sprintf("got: %s, want: %s", test.expected.query, req.URL.RawQuery) - http.Error(rw, msg, http.StatusBadRequest) - return - } + client := servermock.NewBuilder[*Client](setupClient("")). + Route("POST /delete-record.json", + servermock.RawStringResponse(test.apiResponse), + servermock.CheckQueryParameter().Strict(). + WithValues(test.expected.query), + ). + Build(t) - handlerMock(http.MethodPost, []byte(test.apiResponse))(rw, req) - })) - t.Cleanup(server.Close) - - client, err := NewClient("myAuthID", "", "myAuthPassword") - require.NoError(t, err) - - client.BaseURL, _ = url.Parse(server.URL) - - err = client.RemoveTxtRecord(t.Context(), test.id, test.zoneName) + err := client.RemoveTxtRecord(t.Context(), test.id, test.zoneName) if test.expected.errorMsg != "" { require.EqualError(t, err, test.expected.errorMsg) @@ -589,13 +644,15 @@ func TestClient_GetUpdateStatus(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - server := httptest.NewServer(handlerMock(http.MethodGet, []byte(test.apiResponse))) - t.Cleanup(server.Close) - - client, err := NewClient("myAuthID", "", "myAuthPassword") - require.NoError(t, err) - - client.BaseURL, _ = url.Parse(server.URL) + client := servermock.NewBuilder[*Client](setupClient("")). + Route("GET /update-status.json", + servermock.RawStringResponse(test.apiResponse), + servermock.CheckQueryParameter().Strict(). + With("auth-id", "myAuthID"). + With("auth-password", "myAuthPassword"). + With("domain-name", test.zoneName), + ). + Build(t) syncProgress, err := client.GetUpdateStatus(t.Context(), test.zoneName) diff --git a/providers/dns/cloudru/internal/client_test.go b/providers/dns/cloudru/internal/client_test.go index 21e227f76..3b087d617 100644 --- a/providers/dns/cloudru/internal/client_test.go +++ b/providers/dns/cloudru/internal/client_test.go @@ -1,62 +1,40 @@ package internal import ( - "fmt" - "io" - "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" "time" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, pattern string, handler http.HandlerFunc) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("user", "secret") + client.HTTPClient = server.Client() + client.APIEndpoint, _ = url.Parse(server.URL) + client.token = &Token{ + AccessToken: "secret", + ExpiresIn: 60, + TokenType: "Bearer", + Deadline: time.Now().Add(1 * time.Minute), + } - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc(pattern, handler) - - client := NewClient("user", "secret") - client.HTTPClient = server.Client() - client.APIEndpoint, _ = url.Parse(server.URL) - client.token = &Token{ - AccessToken: "secret", - ExpiresIn: 60, - TokenType: "Bearer", - Deadline: time.Now().Add(1 * time.Minute), - } - - return client -} - -func writeFixtureHandler(method, filename string) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, _ = io.Copy(rw, file) - } + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("Bearer xxx")) } func TestClient_GetZones(t *testing.T) { - client := setupTest(t, "/zones", writeFixtureHandler(http.MethodGet, "zones.json")) + client := mockBuilder(). + Route("GET /zones", + servermock.ResponseFromFixture("zones.json")). + Build(t) ctx := mockContext(t) @@ -78,7 +56,10 @@ func TestClient_GetZones(t *testing.T) { } func TestClient_GetRecords(t *testing.T) { - client := setupTest(t, "/zones/zzz/records", writeFixtureHandler(http.MethodGet, "records.json")) + client := mockBuilder(). + Route("GET /zones/zzz/records", + servermock.ResponseFromFixture("records.json")). + Build(t) ctx := mockContext(t) @@ -122,7 +103,11 @@ func TestClient_GetRecords(t *testing.T) { } func TestClient_CreateRecord(t *testing.T) { - client := setupTest(t, "/zones/zzz/records", writeFixtureHandler(http.MethodPost, "record.json")) + client := mockBuilder(). + Route("POST /zones/zzz/records", + servermock.ResponseFromFixture("record.json"), + servermock.CheckRequestJSONBody(`{"name":"www.example.com.","type":"TXT","values":["text"],"ttl":"3600"}`)). + Build(t) ctx := mockContext(t) @@ -150,7 +135,10 @@ func TestClient_CreateRecord(t *testing.T) { } func TestClient_DeleteRecord(t *testing.T) { - client := setupTest(t, "/zones/zzz/records/example.com/TXT", writeFixtureHandler(http.MethodDelete, "record.json")) + client := mockBuilder(). + Route("DELETE /zones/zzz/records/example.com/TXT", + servermock.ResponseFromFixture("record.json")). + Build(t) ctx := mockContext(t) diff --git a/providers/dns/cloudru/internal/identity_test.go b/providers/dns/cloudru/internal/identity_test.go index 68dbd90cd..c1097c015 100644 --- a/providers/dns/cloudru/internal/identity_test.go +++ b/providers/dns/cloudru/internal/identity_test.go @@ -2,13 +2,11 @@ package internal import ( "context" - "encoding/json" - "fmt" - "net/http" "net/http/httptest" "net/url" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -19,47 +17,33 @@ func mockContext(t *testing.T) context.Context { return context.WithValue(t.Context(), tokenKey, &Token{AccessToken: "xxx"}) } -func tokenHandler(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("invalid method, got %s want %s", req.Method, http.MethodPost), http.StatusMethodNotAllowed) - return - } - - err := req.ParseForm() - if err != nil { - http.Error(rw, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) - return - } - - grantType := req.Form.Get("grant_type") - clientID := req.Form.Get("client_id") - clientSecret := req.Form.Get("client_secret") - - if clientID != "user" || clientSecret != "secret" || grantType != "access_key" { - http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return - } - - _ = json.NewEncoder(rw).Encode(Token{ - AccessToken: "xxx", - TokenID: "yyy", - ExpiresIn: 666, - TokenType: "Bearer", - Scope: "openid profile email roles", - }) -} - -func TestClient_obtainToken(t *testing.T) { - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc("/", tokenHandler) - +func setupIdentityClient(server *httptest.Server) (*Client, error) { client := NewClient("user", "secret") client.HTTPClient = server.Client() client.AuthEndpoint, _ = url.Parse(server.URL) + return client, nil +} + +func TestClient_obtainToken(t *testing.T) { + client := servermock.NewBuilder[*Client](setupIdentityClient, + servermock.CheckHeader(). + WithContentTypeFromURLEncoded(), + ). + Route("POST /", servermock.JSONEncode(Token{ + AccessToken: "xxx", + TokenID: "yyy", + ExpiresIn: 666, + TokenType: "Bearer", + Scope: "openid profile email roles", + }), + servermock.CheckForm().Strict(). + With("client_id", "user"). + With("client_secret", "secret"). + With("grant_type", "access_key"), + ). + Build(t) + assert.Nil(t, client.token) tok, err := client.obtainToken(t.Context()) @@ -71,15 +55,23 @@ func TestClient_obtainToken(t *testing.T) { } func TestClient_CreateAuthenticatedContext(t *testing.T) { - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc("/", tokenHandler) - - client := NewClient("user", "secret") - client.HTTPClient = server.Client() - client.AuthEndpoint, _ = url.Parse(server.URL) + client := servermock.NewBuilder[*Client](setupIdentityClient, + servermock.CheckHeader(). + WithContentTypeFromURLEncoded(), + ). + Route("POST /", servermock.JSONEncode(Token{ + AccessToken: "xxx", + TokenID: "yyy", + ExpiresIn: 666, + TokenType: "Bearer", + Scope: "openid profile email roles", + }), + servermock.CheckForm().Strict(). + With("client_id", "user"). + With("client_secret", "secret"). + With("grant_type", "access_key"), + ). + Build(t) assert.Nil(t, client.token) diff --git a/providers/dns/conoha/internal/client_test.go b/providers/dns/conoha/internal/client_test.go index 0cabb30dd..0b9242c08 100644 --- a/providers/dns/conoha/internal/client_test.go +++ b/providers/dns/conoha/internal/client_test.go @@ -11,60 +11,26 @@ import ( "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient("tyo1", "secret") + if err != nil { + return nil, err + } - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) - client, err := NewClient("tyo1", "secret") - require.NoError(t, err) - - client.HTTPClient = server.Client() - client.baseURL, _ = url.Parse(server.URL) - - return client, mux -} - -func writeFixtureHandler(method, filename string) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - writeFixture(rw, filename) - } -} - -func writeBodyHandler(method, content string) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - _, err := fmt.Fprint(rw, content) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - } -} - -func writeFixture(rw http.ResponseWriter, filename string) { - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, _ = io.Copy(rw, file) + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + With("X-Auth-Token", "secret")) } func TestClient_GetDomainID(t *testing.T) { @@ -76,34 +42,34 @@ func TestClient_GetDomainID(t *testing.T) { testCases := []struct { desc string domainName string - handler http.HandlerFunc + response string expected expected }{ { desc: "success", domainName: "domain1.com.", - handler: writeFixtureHandler(http.MethodGet, "domains_GET.json"), + response: "domains_GET.json", expected: expected{domainID: "09494b72-b65b-4297-9efb-187f65a0553e"}, }, { desc: "non existing domain", domainName: "domain1.com.", - handler: writeBodyHandler(http.MethodGet, "{}"), + response: "empty.json", expected: expected{error: true}, }, { desc: "marshaling error", domainName: "domain1.com.", - handler: writeBodyHandler(http.MethodGet, "[]"), + response: "empty.json", expected: expected{error: true}, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - client, mux := setupTest(t) - - mux.Handle("/v1/domains", test.handler) + client := mockBuilder(). + Route("GET /v1/domains", servermock.ResponseFromFixture(test.response)). + Build(t) domainID, err := client.GetDomainID(t.Context(), test.domainName) @@ -126,11 +92,6 @@ func TestClient_CreateRecord(t *testing.T) { { desc: "success", handler: func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - raw, err := io.ReadAll(req.Body) if err != nil { http.Error(rw, err.Error(), http.StatusBadRequest) @@ -143,18 +104,20 @@ func TestClient_CreateRecord(t *testing.T) { return } - writeFixture(rw, "domains-records_POST.json") + file, err := os.Open(filepath.Join("fixtures", "domains-records_POST.json")) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + defer func() { _ = file.Close() }() + + _, _ = io.Copy(rw, file) }, assert: require.NoError, }, { desc: "bad request", handler: func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - http.Error(rw, "OOPS", http.StatusBadRequest) }, assert: require.Error, @@ -163,9 +126,9 @@ func TestClient_CreateRecord(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - client, mux := setupTest(t) - - mux.Handle("/v1/domains/lego/records", test.handler) + client := mockBuilder(). + Route("POST /v1/domains/lego/records", test.handler). + Build(t) domainID := "lego" @@ -183,10 +146,10 @@ func TestClient_CreateRecord(t *testing.T) { } func TestClient_GetRecordID(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/v1/domains/89acac79-38e7-497d-807c-a011e1310438/records", - writeFixtureHandler(http.MethodGet, "domains-records_GET.json")) + client := mockBuilder(). + Route("GET /v1/domains/89acac79-38e7-497d-807c-a011e1310438/records", + servermock.ResponseFromFixture("domains-records_GET.json")). + Build(t) recordID, err := client.GetRecordID(t.Context(), "89acac79-38e7-497d-807c-a011e1310438", "www.example.com.", "A", "15.185.172.153") require.NoError(t, err) @@ -195,16 +158,10 @@ func TestClient_GetRecordID(t *testing.T) { } func TestClient_DeleteRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/v1/domains/89acac79-38e7-497d-807c-a011e1310438/records/2e32e609-3a4f-45ba-bdef-e50eacd345ad", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodDelete { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - rw.WriteHeader(http.StatusOK) - }) + client := mockBuilder(). + Route("DELETE /v1/domains/89acac79-38e7-497d-807c-a011e1310438/records/2e32e609-3a4f-45ba-bdef-e50eacd345ad", + servermock.ResponseFromFixture("domains-records_GET.json")). + Build(t) err := client.DeleteRecord(t.Context(), "89acac79-38e7-497d-807c-a011e1310438", "2e32e609-3a4f-45ba-bdef-e50eacd345ad") require.NoError(t, err) diff --git a/providers/dns/conoha/internal/fixtures/empty.json b/providers/dns/conoha/internal/fixtures/empty.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/providers/dns/conoha/internal/fixtures/empty.json @@ -0,0 +1 @@ +{} diff --git a/providers/dns/conoha/internal/identity_test.go b/providers/dns/conoha/internal/identity_test.go index 77db51f09..0bd4c936a 100644 --- a/providers/dns/conoha/internal/identity_test.go +++ b/providers/dns/conoha/internal/identity_test.go @@ -1,27 +1,33 @@ package internal import ( - "net/http" "net/http/httptest" "net/url" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestNewClient(t *testing.T) { - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - +func setupIdentifier(server *httptest.Server) (*Identifier, error) { identifier, err := NewIdentifier("tyo1") - require.NoError(t, err) + if err != nil { + return nil, err + } identifier.HTTPClient = server.Client() identifier.baseURL, _ = url.Parse(server.URL) - mux.HandleFunc("/v2.0/tokens", writeFixtureHandler(http.MethodPost, "tokens_POST.json")) + return identifier, nil +} + +func TestNewClient(t *testing.T) { + identifier := servermock.NewBuilder[*Identifier](setupIdentifier, + servermock.CheckHeader().WithJSONHeaders(), + ). + Route("POST /v2.0/tokens", servermock.ResponseFromFixture("tokens_POST.json")). + Build(t) auth := Auth{ TenantID: "487727e3921d44e3bfe7ebb337bf085e", diff --git a/providers/dns/conohav3/internal/client_test.go b/providers/dns/conohav3/internal/client_test.go index 9600b2f06..babdadf7e 100644 --- a/providers/dns/conohav3/internal/client_test.go +++ b/providers/dns/conohav3/internal/client_test.go @@ -11,60 +11,27 @@ import ( "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient("c3j1", "secret") + if err != nil { + return nil, err + } - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) - client, err := NewClient("c3j1", "secret") - require.NoError(t, err) - - client.HTTPClient = server.Client() - client.baseURL, _ = url.Parse(server.URL) - - return client, mux -} - -func writeFixtureHandler(method, filename string) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - writeFixture(rw, filename) - } -} - -func writeBodyHandler(method, content string) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - _, err := fmt.Fprint(rw, content) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - } -} - -func writeFixture(rw http.ResponseWriter, filename string) { - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, _ = io.Copy(rw, file) + return client, nil + }, + servermock.CheckHeader(). + WithJSONHeaders(). + With("X-Auth-Token", "secret")) } func TestClient_GetDomainID(t *testing.T) { @@ -76,34 +43,34 @@ func TestClient_GetDomainID(t *testing.T) { testCases := []struct { desc string domainName string - handler http.HandlerFunc + response string expected expected }{ { desc: "success", domainName: "domain1.com.", - handler: writeFixtureHandler(http.MethodGet, "domains_GET.json"), + response: "domains_GET.json", expected: expected{domainID: "09494b72-b65b-4297-9efb-187f65a0553e"}, }, { desc: "non existing domain", domainName: "domain1.com.", - handler: writeBodyHandler(http.MethodGet, "{}"), + response: "empty.json", expected: expected{error: true}, }, { desc: "marshaling error", domainName: "domain1.com.", - handler: writeBodyHandler(http.MethodGet, "[]"), + response: "empty.json", expected: expected{error: true}, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - client, mux := setupTest(t) - - mux.Handle("/v1/domains", test.handler) + client := mockBuilder(). + Route("GET /v1/domains", servermock.ResponseFromFixture(test.response)). + Build(t) domainID, err := client.GetDomainID(t.Context(), test.domainName) @@ -126,11 +93,6 @@ func TestClient_CreateRecord(t *testing.T) { { desc: "success", handler: func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - raw, err := io.ReadAll(req.Body) if err != nil { http.Error(rw, err.Error(), http.StatusBadRequest) @@ -143,18 +105,20 @@ func TestClient_CreateRecord(t *testing.T) { return } - writeFixture(rw, "domains-records_POST.json") + file, err := os.Open(filepath.Join("fixtures", "domains-records_POST.json")) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + defer func() { _ = file.Close() }() + + _, _ = io.Copy(rw, file) }, assert: require.NoError, }, { desc: "bad request", handler: func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - http.Error(rw, "OOPS", http.StatusBadRequest) }, assert: require.Error, @@ -163,9 +127,9 @@ func TestClient_CreateRecord(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - client, mux := setupTest(t) - - mux.Handle("/v1/domains/lego/records", test.handler) + client := mockBuilder(). + Route("POST /v1/domains/lego/records", test.handler). + Build(t) domainID := "lego" @@ -183,10 +147,10 @@ func TestClient_CreateRecord(t *testing.T) { } func TestClient_GetRecordID(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/v1/domains/89acac79-38e7-497d-807c-a011e1310438/records", - writeFixtureHandler(http.MethodGet, "domains-records_GET.json")) + client := mockBuilder(). + Route("GET /v1/domains/89acac79-38e7-497d-807c-a011e1310438/records", + servermock.ResponseFromFixture("domains-records_GET.json")). + Build(t) recordID, err := client.GetRecordID(t.Context(), "89acac79-38e7-497d-807c-a011e1310438", "www.example.com.", "A", "15.185.172.153") require.NoError(t, err) @@ -195,16 +159,10 @@ func TestClient_GetRecordID(t *testing.T) { } func TestClient_DeleteRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/v1/domains/89acac79-38e7-497d-807c-a011e1310438/records/2e32e609-3a4f-45ba-bdef-e50eacd345ad", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodDelete { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - rw.WriteHeader(http.StatusOK) - }) + client := mockBuilder(). + Route("DELETE /v1/domains/89acac79-38e7-497d-807c-a011e1310438/records/2e32e609-3a4f-45ba-bdef-e50eacd345ad", + servermock.ResponseFromFixture("domains-records_GET.json")). + Build(t) err := client.DeleteRecord(t.Context(), "89acac79-38e7-497d-807c-a011e1310438", "2e32e609-3a4f-45ba-bdef-e50eacd345ad") require.NoError(t, err) diff --git a/providers/dns/conohav3/internal/fixtures/empty.json b/providers/dns/conohav3/internal/fixtures/empty.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/providers/dns/conohav3/internal/fixtures/empty.json @@ -0,0 +1 @@ +{} diff --git a/providers/dns/conohav3/internal/identity_test.go b/providers/dns/conohav3/internal/identity_test.go index d5222c05d..d479a18d9 100644 --- a/providers/dns/conohav3/internal/identity_test.go +++ b/providers/dns/conohav3/internal/identity_test.go @@ -6,26 +6,32 @@ import ( "net/url" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestGetToken_HeaderToken(t *testing.T) { - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - +func setupIdentifier(server *httptest.Server) (*Identifier, error) { identifier, err := NewIdentifier("c3j1") - require.NoError(t, err) + if err != nil { + return nil, err + } identifier.HTTPClient = server.Client() identifier.baseURL, _ = url.Parse(server.URL) - mux.HandleFunc("/v3/auth/tokens", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("x-subject-token", "sample-header-token-123") - w.WriteHeader(http.StatusCreated) - _, _ = w.Write([]byte(`{}`)) - }) + return identifier, nil +} + +func TestGetToken_HeaderToken(t *testing.T) { + identifier := servermock.NewBuilder[*Identifier](setupIdentifier, + servermock.CheckHeader().WithJSONHeaders(), + ). + Route("POST /v3/auth/tokens", + servermock.ResponseFromFixture("empty.json"). + WithStatusCode(http.StatusCreated). + WithHeader("x-subject-token", "sample-header-token-123")). + Build(t) auth := Auth{ Identity: Identity{ diff --git a/providers/dns/constellix/internal/domains_test.go b/providers/dns/constellix/internal/domains_test.go index f6ade9d31..2d92fb8f3 100644 --- a/providers/dns/constellix/internal/domains_test.go +++ b/providers/dns/constellix/internal/domains_test.go @@ -1,51 +1,30 @@ package internal import ( - "io" - "net/http" "net/http/httptest" - "os" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient(server.Client()) + client.BaseURL = server.URL - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := NewClient(server.Client()) - client.BaseURL = server.URL - - return client, mux + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(), + ) } func TestDomainService_GetAll(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/v1/domains", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - file, err := os.Open("./fixtures/domains-GetAll.json") - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder(). + Route("GET /v1/domains", servermock.ResponseFromFixture("domains-GetAll.json")). + Build(t) data, err := client.Domains.GetAll(t.Context(), nil) require.NoError(t, err) @@ -61,27 +40,12 @@ func TestDomainService_GetAll(t *testing.T) { } func TestDomainService_Search(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/v1/domains/search", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - file, err := os.Open("./fixtures/domains-Search.json") - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder(). + Route("GET /v1/domains/search", + servermock.ResponseFromFixture("domains-Search.json"), + servermock.CheckQueryParameter().Strict(). + With("exact", "lego.wtf")). + Build(t) data, err := client.Domains.Search(t.Context(), Exact, "lego.wtf") require.NoError(t, err) diff --git a/providers/dns/constellix/internal/txtrecords_test.go b/providers/dns/constellix/internal/txtrecords_test.go index ee4d20bf2..54d10dc38 100644 --- a/providers/dns/constellix/internal/txtrecords_test.go +++ b/providers/dns/constellix/internal/txtrecords_test.go @@ -2,37 +2,19 @@ package internal import ( "encoding/json" - "io" - "net/http" "os" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestTxtRecordService_Create(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/v1/domains/12345/records/txt", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - file, err := os.Open("./fixtures/records-Create.json") - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder(). + Route("POST /v1/domains/12345/records/txt", servermock.ResponseFromFixture("records-Create.json"), + servermock.CheckRequestJSONBody(`{"name":""}`)). + Build(t) records, err := client.TxtRecords.Create(t.Context(), 12345, RecordRequest{}) require.NoError(t, err) @@ -47,27 +29,9 @@ func TestTxtRecordService_Create(t *testing.T) { } func TestTxtRecordService_GetAll(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/v1/domains/12345/records/txt", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - file, err := os.Open("./fixtures/records-GetAll.json") - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder(). + Route("GET /v1/domains/12345/records/txt", servermock.ResponseFromFixture("records-GetAll.json")). + Build(t) records, err := client.TxtRecords.GetAll(t.Context(), 12345) require.NoError(t, err) @@ -82,27 +46,9 @@ func TestTxtRecordService_GetAll(t *testing.T) { } func TestTxtRecordService_Get(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/v1/domains/12345/records/txt/6789", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - file, err := os.Open("./fixtures/records-Get.json") - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder(). + Route("GET /v1/domains/12345/records/txt/6789", servermock.ResponseFromFixture("records-Get.json")). + Build(t) record, err := client.TxtRecords.Get(t.Context(), 12345, 6789) require.NoError(t, err) @@ -130,20 +76,10 @@ func TestTxtRecordService_Get(t *testing.T) { } func TestTxtRecordService_Update(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/v1/domains/12345/records/txt/6789", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPut { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - _, err := rw.Write([]byte(`{"success":"Record updated successfully"}`)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder(). + Route("PUT /v1/domains/12345/records/txt/6789", + servermock.RawStringResponse(`{"success":"Record updated successfully"}`)). + Build(t) msg, err := client.TxtRecords.Update(t.Context(), 12345, 6789, RecordRequest{}) require.NoError(t, err) @@ -153,20 +89,10 @@ func TestTxtRecordService_Update(t *testing.T) { } func TestTxtRecordService_Delete(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/v1/domains/12345/records/txt/6789", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodDelete { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - _, err := rw.Write([]byte(`{"success":"Record deleted successfully"}`)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder(). + Route("DELETE /v1/domains/12345/records/txt/6789", + servermock.RawStringResponse(`{"success":"Record deleted successfully"}`)). + Build(t) msg, err := client.TxtRecords.Delete(t.Context(), 12345, 6789) require.NoError(t, err) @@ -176,27 +102,9 @@ func TestTxtRecordService_Delete(t *testing.T) { } func TestTxtRecordService_Search(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/v1/domains/12345/records/txt/search", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - file, err := os.Open("./fixtures/records-Search.json") - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder(). + Route("GET /v1/domains/12345/records/txt/search", servermock.ResponseFromFixture("records-Search.json")). + Build(t) records, err := client.TxtRecords.Search(t.Context(), 12345, Exact, "test") require.NoError(t, err) diff --git a/providers/dns/corenetworks/internal/client_test.go b/providers/dns/corenetworks/internal/client_test.go index ec6de452e..ca5c81a65 100644 --- a/providers/dns/corenetworks/internal/client_test.go +++ b/providers/dns/corenetworks/internal/client_test.go @@ -1,112 +1,34 @@ package internal import ( - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("user", "secret") + client.baseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := NewClient("user", "secret") - client.baseURL, _ = url.Parse(server.URL) - client.HTTPClient = server.Client() - - return client, mux -} - -func testHandler(method string, statusCode int, filename string) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf(`unsupported method: %s`, req.Method), http.StatusMethodNotAllowed) - return - } - - rw.WriteHeader(statusCode) - - if statusCode == http.StatusNoContent { - return - } - - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, fmt.Sprintf(`message %v`, err), http.StatusInternalServerError) - return - } - - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, fmt.Sprintf(`message %v`, err), http.StatusInternalServerError) - return - } - } -} - -func testHandlerAuth(method string, statusCode int, filename string) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf(`{"message":"unsupported method: %s"}`, req.Method), http.StatusMethodNotAllowed) - return - } - - rw.WriteHeader(statusCode) - - if statusCode == http.StatusNoContent { - return - } - - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, fmt.Sprintf(`{"message":"%v"}`, err), http.StatusInternalServerError) - return - } - - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, fmt.Sprintf(`{"message":"%v"}`, err), http.StatusInternalServerError) - return - } - } -} - -func TestClient_CreateAuthenticationToken(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/auth/token", testHandlerAuth(http.MethodPost, http.StatusOK, "auth.json")) - - ctx := t.Context() - - token, err := client.CreateAuthenticationToken(ctx) - require.NoError(t, err) - - expected := &Token{ - Token: "authsecret", - Expires: 123, - } - assert.Equal(t, expected, token) + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(), + ) } func TestClient_ListZone(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/dnszones/", testHandler(http.MethodGet, http.StatusOK, "ListZone.json")) + client := mockBuilder(). + Route("GET /dnszones/", + servermock.ResponseFromFixture("ListZone.json")). + Build(t) ctx := t.Context() @@ -122,13 +44,12 @@ func TestClient_ListZone(t *testing.T) { } func TestClient_GetZoneDetails(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("GET /dnszones/example.com", + servermock.ResponseFromFixture("GetZoneDetails.json")). + Build(t) - mux.HandleFunc("/dnszones/example.com", testHandler(http.MethodGet, http.StatusOK, "GetZoneDetails.json")) - - ctx := t.Context() - - zone, err := client.GetZoneDetails(ctx, "example.com") + zone, err := client.GetZoneDetails(t.Context(), "example.com") require.NoError(t, err) expected := &ZoneDetails{ @@ -142,13 +63,12 @@ func TestClient_GetZoneDetails(t *testing.T) { } func TestClient_ListRecords(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("GET /dnszones/example.com/records/", + servermock.ResponseFromFixture("ListRecords.json")). + Build(t) - mux.HandleFunc("/dnszones/example.com/records/", testHandler(http.MethodGet, http.StatusOK, "ListRecords.json")) - - ctx := t.Context() - - records, err := client.ListRecords(ctx, "example.com") + records, err := client.ListRecords(t.Context(), "example.com") require.NoError(t, err) expected := []Record{ @@ -176,38 +96,35 @@ func TestClient_ListRecords(t *testing.T) { } func TestClient_AddRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/dnszones/example.com/records/", testHandler(http.MethodPost, http.StatusNoContent, "")) - - ctx := t.Context() + client := mockBuilder(). + Route("POST /dnszones/example.com/records/", + servermock.Noop().WithStatusCode(http.StatusNoContent)). + Build(t) record := Record{Name: "www", TTL: 3600, Type: "A", Data: "127.0.0.1"} - err := client.AddRecord(ctx, "example.com", record) + err := client.AddRecord(t.Context(), "example.com", record) require.NoError(t, err) } func TestClient_DeleteRecords(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/dnszones/example.com/records/delete", testHandler(http.MethodPost, http.StatusNoContent, "")) - - ctx := t.Context() + client := mockBuilder(). + Route("POST /dnszones/example.com/records/delete", + servermock.Noop().WithStatusCode(http.StatusNoContent)). + Build(t) record := Record{Name: "www", Type: "A", Data: "127.0.0.1"} - err := client.DeleteRecords(ctx, "example.com", record) + err := client.DeleteRecords(t.Context(), "example.com", record) require.NoError(t, err) } func TestClient_CommitRecords(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("POST /dnszones/example.com/records/commit", + servermock.Noop().WithStatusCode(http.StatusNoContent)). + Build(t) - mux.HandleFunc("/dnszones/example.com/records/commit", testHandler(http.MethodPost, http.StatusNoContent, "")) - - ctx := t.Context() - - err := client.CommitRecords(ctx, "example.com") + err := client.CommitRecords(t.Context(), "example.com") require.NoError(t, err) } diff --git a/providers/dns/corenetworks/internal/identity_test.go b/providers/dns/corenetworks/internal/identity_test.go new file mode 100644 index 000000000..b5e05ed3f --- /dev/null +++ b/providers/dns/corenetworks/internal/identity_test.go @@ -0,0 +1,24 @@ +package internal + +import ( + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestClient_CreateAuthenticationToken(t *testing.T) { + client := mockBuilder(). + Route("POST /auth/token", servermock.ResponseFromFixture("auth.json")). + Build(t) + + token, err := client.CreateAuthenticationToken(t.Context()) + require.NoError(t, err) + + expected := &Token{ + Token: "authsecret", + Expires: 123, + } + assert.Equal(t, expected, token) +} diff --git a/providers/dns/cpanel/internal/cpanel/client_test.go b/providers/dns/cpanel/internal/cpanel/client_test.go index 78c45e82d..533d1130d 100644 --- a/providers/dns/cpanel/internal/cpanel/client_test.go +++ b/providers/dns/cpanel/internal/cpanel/client_test.go @@ -1,58 +1,38 @@ package cpanel import ( - "fmt" - "io" - "net/http" "net/http/httptest" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/go-acme/lego/v4/providers/dns/cpanel/internal/shared" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, pattern, filename string) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient(server.URL, "user", "secret") + if err != nil { + return nil, err + } - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) + client.HTTPClient = server.Client() - mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - open, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = open.Close() }() - - rw.WriteHeader(http.StatusOK) - _, err = io.Copy(rw, open) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - client, err := NewClient(server.URL, "user", "secret") - require.NoError(t, err) - - client.HTTPClient = server.Client() - - return client + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("cpanel user:secret")) } func TestClient_FetchZoneInformation(t *testing.T) { - client := setupTest(t, "/execute/DNS/parse_zone", "zone-info.json") + client := mockBuilder(). + Route("GET /execute/DNS/parse_zone", + servermock.ResponseFromFixture("zone-info.json"), + servermock.CheckQueryParameter().Strict(). + With("zone", "example.com")). + Build(t) zoneInfo, err := client.FetchZoneInformation(t.Context(), "example.com") require.NoError(t, err) @@ -70,16 +50,27 @@ func TestClient_FetchZoneInformation(t *testing.T) { } func TestClient_FetchZoneInformation_error(t *testing.T) { - client := setupTest(t, "/execute/DNS/parse_zone", "zone-info_error.json") + client := mockBuilder(). + Route("GET /execute/DNS/parse_zone", + servermock.ResponseFromFixture("zone-info_error.json")). + Build(t) zoneInfo, err := client.FetchZoneInformation(t.Context(), "example.com") - require.Error(t, err) + require.EqualError(t, err, "error(0): You do not control a DNS zone named example.com.: a, b, c") assert.Nil(t, zoneInfo) } func TestClient_AddRecord(t *testing.T) { - client := setupTest(t, "/execute/DNS/mass_edit_zone", "update-zone.json") + client := mockBuilder(). + Route("GET /execute/DNS/mass_edit_zone", + servermock.ResponseFromFixture("update-zone.json"), + servermock.CheckQueryParameter().Strict(). + With("zone", "example.com"). + With("add", `{"dname":"example","ttl":14400,"record_type":"TXT","data":["string1","string2"]}`). + With("serial", "123456"). + With("zone", "example.com")). + Build(t) record := shared.Record{ DName: "example", @@ -97,7 +88,10 @@ func TestClient_AddRecord(t *testing.T) { } func TestClient_AddRecord_error(t *testing.T) { - client := setupTest(t, "/execute/DNS/mass_edit_zone", "update-zone_error.json") + client := mockBuilder(). + Route("GET /execute/DNS/mass_edit_zone", + servermock.ResponseFromFixture("update-zone_error.json")). + Build(t) record := shared.Record{ DName: "example", @@ -113,7 +107,14 @@ func TestClient_AddRecord_error(t *testing.T) { } func TestClient_EditRecord(t *testing.T) { - client := setupTest(t, "/execute/DNS/mass_edit_zone", "update-zone.json") + client := mockBuilder(). + Route("GET /execute/DNS/mass_edit_zone", + servermock.ResponseFromFixture("update-zone.json"), + servermock.CheckQueryParameter().Strict(). + With("edit", `{"dname":"example","ttl":14400,"record_type":"TXT","data":["string1","string2"],"line_index":9}`). + With("serial", "123456"). + With("zone", "example.com")). + Build(t) record := shared.Record{ LineIndex: 9, @@ -132,7 +133,10 @@ func TestClient_EditRecord(t *testing.T) { } func TestClient_EditRecord_error(t *testing.T) { - client := setupTest(t, "/execute/DNS/mass_edit_zone", "update-zone_error.json") + client := mockBuilder(). + Route("GET /execute/DNS/mass_edit_zone", + servermock.ResponseFromFixture("update-zone_error.json")). + Build(t) record := shared.Record{ LineIndex: 9, @@ -149,7 +153,14 @@ func TestClient_EditRecord_error(t *testing.T) { } func TestClient_DeleteRecord(t *testing.T) { - client := setupTest(t, "/execute/DNS/mass_edit_zone", "update-zone.json") + client := mockBuilder(). + Route("GET /execute/DNS/mass_edit_zone", + servermock.ResponseFromFixture("update-zone.json"), + servermock.CheckQueryParameter().Strict(). + With("remove", "0"). + With("serial", "123456"). + With("zone", "example.com")). + Build(t) zoneSerial, err := client.DeleteRecord(t.Context(), 123456, "example.com", 0) require.NoError(t, err) @@ -160,7 +171,10 @@ func TestClient_DeleteRecord(t *testing.T) { } func TestClient_DeleteRecord_error(t *testing.T) { - client := setupTest(t, "/execute/DNS/mass_edit_zone", "update-zone_error.json") + client := mockBuilder(). + Route("GET /execute/DNS/mass_edit_zone", + servermock.ResponseFromFixture("update-zone_error.json")). + Build(t) zoneSerial, err := client.DeleteRecord(t.Context(), 123456, "example.com", 0) require.Error(t, err) diff --git a/providers/dns/cpanel/internal/whm/client_test.go b/providers/dns/cpanel/internal/whm/client_test.go index 536417666..47686bf09 100644 --- a/providers/dns/cpanel/internal/whm/client_test.go +++ b/providers/dns/cpanel/internal/whm/client_test.go @@ -1,58 +1,39 @@ package whm import ( - "fmt" - "io" - "net/http" "net/http/httptest" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/go-acme/lego/v4/providers/dns/cpanel/internal/shared" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, pattern, filename string) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient(server.URL, "user", "secret") + if err != nil { + return nil, err + } - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) + client.HTTPClient = server.Client() - mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - open, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = open.Close() }() - - rw.WriteHeader(http.StatusOK) - _, err = io.Copy(rw, open) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - client, err := NewClient(server.URL, "user", "secret") - require.NoError(t, err) - - client.HTTPClient = server.Client() - - return client + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("whm user:secret")) } func TestClient_FetchZoneInformation(t *testing.T) { - client := setupTest(t, "/json-api/parse_dns_zone", "zone-info.json") + client := mockBuilder(). + Route("GET /json-api/parse_dns_zone", + servermock.ResponseFromFixture("zone-info.json"), + servermock.CheckQueryParameter().Strict(). + With("api.version", "1"). + With("zone", "example.com")). + Build(t) zoneInfo, err := client.FetchZoneInformation(t.Context(), "example.com") require.NoError(t, err) @@ -70,7 +51,10 @@ func TestClient_FetchZoneInformation(t *testing.T) { } func TestClient_FetchZoneInformation_error(t *testing.T) { - client := setupTest(t, "/json-api/parse_dns_zone", "zone-info_error.json") + client := mockBuilder(). + Route("GET /json-api/parse_dns_zone", + servermock.ResponseFromFixture("zone-info_error.json")). + Build(t) zoneInfo, err := client.FetchZoneInformation(t.Context(), "example.com") require.Error(t, err) @@ -79,7 +63,15 @@ func TestClient_FetchZoneInformation_error(t *testing.T) { } func TestClient_AddRecord(t *testing.T) { - client := setupTest(t, "/json-api/mass_edit_dns_zone", "update-zone.json") + client := mockBuilder(). + Route("GET /json-api/mass_edit_dns_zone", + servermock.ResponseFromFixture("update-zone.json"), + servermock.CheckQueryParameter().Strict(). + With("add", `{"dname":"example","ttl":14400,"record_type":"TXT","data":["string1","string2"]}`). + With("api.version", "1"). + With("serial", "123456"). + With("zone", "example.com")). + Build(t) record := shared.Record{ DName: "example", @@ -97,7 +89,10 @@ func TestClient_AddRecord(t *testing.T) { } func TestClient_AddRecord_error(t *testing.T) { - client := setupTest(t, "/json-api/mass_edit_dns_zone", "update-zone_error.json") + client := mockBuilder(). + Route("GET /json-api/mass_edit_dns_zone", + servermock.ResponseFromFixture("update-zone_error.json")). + Build(t) record := shared.Record{ DName: "example", @@ -113,7 +108,15 @@ func TestClient_AddRecord_error(t *testing.T) { } func TestClient_EditRecord(t *testing.T) { - client := setupTest(t, "/json-api/mass_edit_dns_zone", "update-zone.json") + client := mockBuilder(). + Route("GET /json-api/mass_edit_dns_zone", + servermock.ResponseFromFixture("update-zone.json"), + servermock.CheckQueryParameter().Strict(). + With("edit", `{"dname":"example","ttl":14400,"record_type":"TXT","data":["string1","string2"],"line_index":9}`). + With("api.version", "1"). + With("serial", "123456"). + With("zone", "example.com")). + Build(t) record := shared.Record{ LineIndex: 9, @@ -132,7 +135,10 @@ func TestClient_EditRecord(t *testing.T) { } func TestClient_EditRecord_error(t *testing.T) { - client := setupTest(t, "/json-api/mass_edit_dns_zone", "update-zone_error.json") + client := mockBuilder(). + Route("GET /json-api/mass_edit_dns_zone", + servermock.ResponseFromFixture("update-zone_error.json")). + Build(t) record := shared.Record{ LineIndex: 9, @@ -149,7 +155,15 @@ func TestClient_EditRecord_error(t *testing.T) { } func TestClient_DeleteRecord(t *testing.T) { - client := setupTest(t, "/json-api/mass_edit_dns_zone", "update-zone.json") + client := mockBuilder(). + Route("GET /json-api/mass_edit_dns_zone", + servermock.ResponseFromFixture("update-zone.json"), + servermock.CheckQueryParameter().Strict(). + With("remove", "0"). + With("api.version", "1"). + With("serial", "123456"). + With("zone", "example.com")). + Build(t) zoneSerial, err := client.DeleteRecord(t.Context(), 123456, "example.com", 0) require.NoError(t, err) @@ -160,7 +174,10 @@ func TestClient_DeleteRecord(t *testing.T) { } func TestClient_DeleteRecord_error(t *testing.T) { - client := setupTest(t, "/json-api/mass_edit_dns_zone", "update-zone_error.json") + client := mockBuilder(). + Route("GET /json-api/mass_edit_dns_zone", + servermock.ResponseFromFixture("update-zone_error.json")). + Build(t) zoneSerial, err := client.DeleteRecord(t.Context(), 123456, "example.com", 0) require.Error(t, err) diff --git a/providers/dns/derak/internal/client_test.go b/providers/dns/derak/internal/client_test.go index 20dea0015..322a7f48c 100644 --- a/providers/dns/derak/internal/client_test.go +++ b/providers/dns/derak/internal/client_test.go @@ -1,80 +1,37 @@ package internal import ( - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" "time" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - +func setupClient(server *httptest.Server) (*Client, error) { client := NewClient("secret") client.baseURL, _ = url.Parse(server.URL) client.zoneEndpoint = server.URL client.HTTPClient = server.Client() - return client, mux + return client, nil } -func testHandler(method string, statusCode int, filename string) func(rw http.ResponseWriter, req *http.Request) { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - username, password, ok := req.BasicAuth() - if !ok { - http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return - } - - if username != "api" { - http.Error(rw, fmt.Sprintf("username: want %s got %s", username, "user"), http.StatusUnauthorized) - return - } - - if password != "secret" { - http.Error(rw, fmt.Sprintf("password: want %s got %s", password, "secret"), http.StatusUnauthorized) - return - } - - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - rw.WriteHeader(statusCode) - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - } +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client](setupClient, + servermock.CheckHeader().WithJSONHeaders(). + WithBasicAuth("api", "secret")) } func TestGetRecords(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords", - testHandler(http.MethodGet, http.StatusOK, "records-GET.json")) + client := mockBuilder(). + Route("GET /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords", + servermock.ResponseFromFixture("records-GET.json")). + Build(t) records, err := client.GetRecords(t.Context(), "47c0ecf6c91243308c649ad1d2d618dd", &GetRecordsParameters{DNSType: "TXT", Content: `"test"'`}) require.NoError(t, err) @@ -134,20 +91,21 @@ func TestGetRecords(t *testing.T) { } func TestGetRecords_error(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords", - testHandler(http.MethodGet, http.StatusUnauthorized, "error.json")) + client := mockBuilder(). + Route("GET /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) _, err := client.GetRecords(t.Context(), "47c0ecf6c91243308c649ad1d2d618dd", &GetRecordsParameters{DNSType: "TXT", Content: `"test"'`}) require.Error(t, err) } func TestGetRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/812bee17a0b440b0bd5ee099a78b839c", - testHandler(http.MethodGet, http.StatusOK, "record-GET.json")) + client := mockBuilder(). + Route("GET /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/812bee17a0b440b0bd5ee099a78b839c", + servermock.ResponseFromFixture("record-GET.json")). + Build(t) record, err := client.GetRecord(t.Context(), "47c0ecf6c91243308c649ad1d2d618dd", "812bee17a0b440b0bd5ee099a78b839c") require.NoError(t, err) @@ -163,20 +121,22 @@ func TestGetRecord(t *testing.T) { } func TestGetRecord_error(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/812bee17a0b440b0bd5ee099a78b839c", - testHandler(http.MethodGet, http.StatusUnauthorized, "error.json")) + client := mockBuilder(). + Route("GET /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) _, err := client.GetRecord(t.Context(), "47c0ecf6c91243308c649ad1d2d618dd", "812bee17a0b440b0bd5ee099a78b839c") require.Error(t, err) } func TestCreateRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords", - testHandler(http.MethodPut, http.StatusCreated, "record-PUT.json")) + client := mockBuilder(). + Route("PUT /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords", + servermock.ResponseFromFixture("record-PUT.json"). + WithStatusCode(http.StatusCreated)). + Build(t) r := Record{ Type: "TXT", @@ -199,10 +159,11 @@ func TestCreateRecord(t *testing.T) { } func TestCreateRecord_error(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords", - testHandler(http.MethodPut, http.StatusUnauthorized, "error.json")) + client := mockBuilder(). + Route("PUT /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) r := Record{ Type: "TXT", @@ -216,10 +177,10 @@ func TestCreateRecord_error(t *testing.T) { } func TestEditRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/eebc813de2f94d67b09d91e10e2d65c2", - testHandler(http.MethodPatch, http.StatusOK, "record-PATCH.json")) + client := mockBuilder(). + Route("PATCH /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/eebc813de2f94d67b09d91e10e2d65c2", + servermock.ResponseFromFixture("record-PATCH.json")). + Build(t) record, err := client.EditRecord(t.Context(), "47c0ecf6c91243308c649ad1d2d618dd", "eebc813de2f94d67b09d91e10e2d65c2", Record{ Content: "foo", @@ -237,10 +198,11 @@ func TestEditRecord(t *testing.T) { } func TestEditRecord_error(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/eebc813de2f94d67b09d91e10e2d65c2", - testHandler(http.MethodPatch, http.StatusUnauthorized, "error.json")) + client := mockBuilder(). + Route("PATCH /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/eebc813de2f94d67b09d91e10e2d65c2", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) _, err := client.EditRecord(t.Context(), "47c0ecf6c91243308c649ad1d2d618dd", "eebc813de2f94d67b09d91e10e2d65c2", Record{ Content: "foo", @@ -249,29 +211,33 @@ func TestEditRecord_error(t *testing.T) { } func TestDeleteRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/653464211b7447a1bee6b8fcb9fb86df", - testHandler(http.MethodDelete, http.StatusOK, "record-DELETE.json")) + client := mockBuilder(). + Route("DELETE /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/653464211b7447a1bee6b8fcb9fb86df", + servermock.ResponseFromFixture("record-DELETE.json")). + Build(t) err := client.DeleteRecord(t.Context(), "47c0ecf6c91243308c649ad1d2d618dd", "653464211b7447a1bee6b8fcb9fb86df") require.NoError(t, err) } func TestDeleteRecord_error(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/653464211b7447a1bee6b8fcb9fb86df", - testHandler(http.MethodDelete, http.StatusUnauthorized, "error.json")) + client := mockBuilder(). + Route("DELETE /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/653464211b7447a1bee6b8fcb9fb86df", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) err := client.DeleteRecord(t.Context(), "47c0ecf6c91243308c649ad1d2d618dd", "653464211b7447a1bee6b8fcb9fb86df") require.Error(t, err) } func TestGetZones(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/", testHandler(http.MethodGet, http.StatusOK, "service-cdn-zones.json")) + client := servermock.NewBuilder[*Client](setupClient, + servermock.CheckHeader(). + WithBasicAuth("api", "secret"), + ). + Route("GET /", servermock.ResponseFromFixture("service-cdn-zones.json")). + Build(t) zones, err := client.GetZones(t.Context()) require.NoError(t, err) @@ -302,9 +268,10 @@ func TestGetZones(t *testing.T) { } func TestGetZones_error(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/", testHandler(http.MethodGet, http.StatusUnauthorized, "error.json")) + client := mockBuilder(). + Route("GET /", servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) _, err := client.GetZones(t.Context()) require.Error(t, err) diff --git a/providers/dns/digitalocean/digitalocean_test.go b/providers/dns/digitalocean/digitalocean_test.go index bfd2d68c0..a01906812 100644 --- a/providers/dns/digitalocean/digitalocean_test.go +++ b/providers/dns/digitalocean/digitalocean_test.go @@ -1,36 +1,30 @@ package digitalocean import ( - "bytes" - "fmt" - "io" "net/http" "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester" - "github.com/stretchr/testify/assert" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) var envTest = tester.NewEnvTest(EnvAuthToken) -func setupTest(t *testing.T) (*DNSProvider, *http.ServeMux) { - t.Helper() +func mockProvider() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.AuthToken = "asdf1234" + config.BaseURL = server.URL + config.HTTPClient = server.Client() - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - config := NewDefaultConfig() - config.AuthToken = "asdf1234" - config.BaseURL = server.URL - config.HTTPClient = server.Client() - - provider, err := NewDNSProviderConfig(config) - require.NoError(t, err) - - return provider, mux + return NewDNSProviderConfig(config) + }, + servermock.CheckHeader(). + WithJSONHeaders(). + With("Authorization", "Bearer asdf1234")) } func TestNewDNSProvider(t *testing.T) { @@ -111,26 +105,9 @@ func TestNewDNSProviderConfig(t *testing.T) { } func TestDNSProvider_Present(t *testing.T) { - provider, mux := setupTest(t) - - mux.HandleFunc("/v2/domains/example.com/records", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodPost, r.Method, "method") - - assert.Equal(t, "application/json", r.Header.Get("Accept"), "Accept") - assert.Equal(t, "application/json", r.Header.Get("Content-Type"), "Content-Type") - assert.Equal(t, "Bearer asdf1234", r.Header.Get("Authorization"), "Authorization") - - reqBody, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - expectedReqBody := `{"type":"TXT","name":"_acme-challenge.example.com.","data":"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI","ttl":30}` - assert.Equal(t, expectedReqBody, string(bytes.TrimSpace(reqBody))) - - w.WriteHeader(http.StatusCreated) - _, err = fmt.Fprintf(w, `{ + provider := mockProvider(). + Route("POST /v2/domains/example.com/records", + servermock.RawStringResponse(`{ "domain_record": { "id": 1234567, "type": "TXT", @@ -140,31 +117,21 @@ func TestDNSProvider_Present(t *testing.T) { "port": null, "weight": null } - }`) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) + }`). + WithStatusCode(http.StatusCreated), + servermock.CheckRequestJSONBody(`{"type":"TXT","name":"_acme-challenge.example.com.","data":"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI","ttl":30}`)). + Build(t) err := provider.Present("example.com", "", "foobar") require.NoError(t, err) } func TestDNSProvider_CleanUp(t *testing.T) { - provider, mux := setupTest(t) - - mux.HandleFunc("/v2/domains/example.com/records/1234567", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodDelete, r.Method, "method") - - assert.Equal(t, "/v2/domains/example.com/records/1234567", r.URL.Path, "Path") - - assert.Equal(t, "application/json", r.Header.Get("Accept"), "Accept") - assert.Equal(t, "application/json", r.Header.Get("Content-Type"), "Content-Type") - assert.Equal(t, "Bearer asdf1234", r.Header.Get("Authorization"), "Authorization") - - w.WriteHeader(http.StatusNoContent) - }) + provider := mockProvider(). + Route("DELETE /v2/domains/example.com/records/1234567", + servermock.Noop(). + WithStatusCode(http.StatusNoContent)). + Build(t) provider.recordIDsMu.Lock() provider.recordIDs["token"] = 1234567 diff --git a/providers/dns/digitalocean/internal/client_test.go b/providers/dns/digitalocean/internal/client_test.go index 171601438..65ce5dfaa 100644 --- a/providers/dns/digitalocean/internal/client_test.go +++ b/providers/dns/digitalocean/internal/client_test.go @@ -1,94 +1,35 @@ package internal import ( - "bytes" - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, pattern string, handler http.HandlerFunc) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient(OAuthStaticAccessToken(server.Client(), "secret")) + client.BaseURL, _ = url.Parse(server.URL) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := NewClient(OAuthStaticAccessToken(server.Client(), "secret")) - client.BaseURL, _ = url.Parse(server.URL) - - mux.HandleFunc(pattern, handler) - - return client -} - -func checkHeader(req *http.Request, name, value string) error { - val := req.Header.Get(name) - if val != value { - return fmt.Errorf("invalid header value, got: %s want %s", val, value) - } - return nil -} - -func writeFixture(rw http.ResponseWriter, filename string) { - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, _ = io.Copy(rw, file) + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("Bearer secret")) } func TestClient_AddTxtRecord(t *testing.T) { - client := setupTest(t, "/v2/domains/example.com/records", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - err := checkHeader(req, "Accept", "application/json") - if err != nil { - http.Error(rw, err.Error(), http.StatusBadRequest) - return - } - - err = checkHeader(req, "Content-Type", "application/json") - if err != nil { - http.Error(rw, err.Error(), http.StatusBadRequest) - return - } - - err = checkHeader(req, "Authorization", "Bearer secret") - if err != nil { - http.Error(rw, err.Error(), http.StatusUnauthorized) - return - } - - reqBody, err := io.ReadAll(req.Body) - if err != nil { - http.Error(rw, err.Error(), http.StatusBadRequest) - return - } - - expectedReqBody := `{"type":"TXT","name":"_acme-challenge.example.com.","data":"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI","ttl":30}` - if expectedReqBody != string(bytes.TrimSpace(reqBody)) { - http.Error(rw, fmt.Sprintf("unexpected request body: %s", string(bytes.TrimSpace(reqBody))), http.StatusBadRequest) - return - } - - rw.WriteHeader(http.StatusCreated) - writeFixture(rw, "domains-records_POST.json") - }) + client := mockBuilder(). + Route("POST /v2/domains/example.com/records", + servermock.ResponseFromFixture("domains-records_POST.json"). + WithStatusCode(http.StatusCreated), + servermock.CheckRequestJSONBody(`{"type":"TXT","name":"_acme-challenge.example.com.","data":"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI","ttl":30}`)). + Build(t) record := Record{ Type: "TXT", @@ -112,26 +53,11 @@ func TestClient_AddTxtRecord(t *testing.T) { } func TestClient_RemoveTxtRecord(t *testing.T) { - client := setupTest(t, "/v2/domains/example.com/records/1234567", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodDelete { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - err := checkHeader(req, "Accept", "application/json") - if err != nil { - http.Error(rw, err.Error(), http.StatusBadRequest) - return - } - - err = checkHeader(req, "Authorization", "Bearer secret") - if err != nil { - http.Error(rw, err.Error(), http.StatusUnauthorized) - return - } - - rw.WriteHeader(http.StatusNoContent) - }) + client := mockBuilder(). + Route("DELETE /v2/domains/example.com/records/1234567", + servermock.ResponseFromFixture("domains-records_POST.json"). + WithStatusCode(http.StatusNoContent)). + Build(t) err := client.RemoveTxtRecord(t.Context(), "example.com", 1234567) require.NoError(t, err) diff --git a/providers/dns/directadmin/internal/client_test.go b/providers/dns/directadmin/internal/client_test.go index 6da73da65..759a7fb4e 100644 --- a/providers/dns/directadmin/internal/client_test.go +++ b/providers/dns/directadmin/internal/client_test.go @@ -1,88 +1,48 @@ package internal import ( - "encoding/json" "fmt" - "io" "net/http" "net/http/httptest" - "net/url" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, _ := NewClient(server.URL, "user", "secret") + client.HTTPClient = server.Client() - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client, _ := NewClient(server.URL, "user", "secret") - client.HTTPClient = server.Client() - - return client, mux + return client, nil + }, + servermock.CheckHeader(). + WithContentTypeFromURLEncoded()) } -func newJSONErrorf(reason string, a ...any) string { - err := APIError{ +func newAPIError(reason string, a ...any) APIError { + return APIError{ Message: "Cannot View Dns Record", Result: fmt.Sprintf(reason, a...), } - - data, _ := json.Marshal(err) - - return string(data) -} - -func testHandler(kv map[string]string) func(rw http.ResponseWriter, req *http.Request) { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - domain := req.URL.Query().Get("domain") - if domain != "example.com" { - http.Error(rw, newJSONErrorf("invalid domain: %s", domain), http.StatusUnauthorized) - return - } - - data, err := io.ReadAll(req.Body) - if err != nil { - http.Error(rw, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - values, err := url.ParseQuery(string(data)) - if err != nil { - http.Error(rw, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - for k, v := range kv { - actual := values.Get(k) - if v != actual { - http.Error(rw, newJSONErrorf("invalid %q: %s", k, actual), http.StatusBadRequest) - return - } - } - } } func TestClient_SetRecord(t *testing.T) { - client, mux := setupTest(t) - - kv := map[string]string{ - "action": "add", - "name": "foo", - "type": "TXT", - "value": "txtTXTtxt", - "ttl": "123", - } - - mux.HandleFunc("/CMD_API_DNS_CONTROL", testHandler(kv)) + client := mockBuilder(). + Route("POST /CMD_API_DNS_CONTROL", nil, + servermock.CheckQueryParameter().Strict(). + With("domain", "example.com"). + With("json", "yes"), + servermock.CheckForm().UsePostForm().Strict(). + With("action", "add"). + With("name", "foo"). + With("type", "TXT"). + With("value", "txtTXTtxt"). + With("ttl", "123"), + ). + Build(t) record := Record{ Name: "foo", @@ -96,11 +56,11 @@ func TestClient_SetRecord(t *testing.T) { } func TestClient_SetRecord_error(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/CMD_API_DNS_CONTROL", func(rw http.ResponseWriter, req *http.Request) { - http.Error(rw, newJSONErrorf("OOPS"), http.StatusInternalServerError) - }) + client := mockBuilder(). + Route("POST /CMD_API_DNS_CONTROL", + servermock.JSONEncode(newAPIError("OOPS")). + WithStatusCode(http.StatusInternalServerError)). + Build(t) record := Record{ Name: "foo", @@ -114,17 +74,18 @@ func TestClient_SetRecord_error(t *testing.T) { } func TestClient_DeleteRecord(t *testing.T) { - client, mux := setupTest(t) - - kv := map[string]string{ - "action": "delete", - "name": "foo", - "type": "TXT", - "value": "txtTXTtxt", - "ttl": "", - } - - mux.HandleFunc("/CMD_API_DNS_CONTROL", testHandler(kv)) + client := mockBuilder(). + Route("POST /CMD_API_DNS_CONTROL", nil, + servermock.CheckQueryParameter().Strict(). + With("domain", "example.com"). + With("json", "yes"), + servermock.CheckForm().UsePostForm().Strict(). + With("action", "delete"). + With("name", "foo"). + With("type", "TXT"). + With("value", "txtTXTtxt"), + ). + Build(t) record := Record{ Name: "foo", @@ -137,11 +98,11 @@ func TestClient_DeleteRecord(t *testing.T) { } func TestClient_DeleteRecord_error(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/CMD_API_DNS_CONTROL", func(rw http.ResponseWriter, req *http.Request) { - http.Error(rw, newJSONErrorf("OOPS"), http.StatusInternalServerError) - }) + client := mockBuilder(). + Route("POST /CMD_API_DNS_CONTROL", + servermock.JSONEncode(newAPIError("OOPS")). + WithStatusCode(http.StatusInternalServerError)). + Build(t) record := Record{ Name: "foo", diff --git a/providers/dns/dnshomede/internal/client_test.go b/providers/dns/dnshomede/internal/client_test.go index 710e2c72e..6e1593fe7 100644 --- a/providers/dns/dnshomede/internal/client_test.go +++ b/providers/dns/dnshomede/internal/client_test.go @@ -2,33 +2,32 @@ package internal import ( "fmt" - "net/http" "net/http/httptest" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, credentials map[string]string, handler http.HandlerFunc) *Client { - t.Helper() +func setupClient(credentials map[string]string) func(server *httptest.Server) (*Client, error) { + return func(server *httptest.Server) (*Client, error) { + client := NewClient(credentials) + client.HTTPClient = server.Client() + client.baseURL = server.URL - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc("/", handler) - - client := NewClient(credentials) - client.HTTPClient = server.Client() - client.baseURL = server.URL - - return client + return client, nil + } } func TestClient_Add(t *testing.T) { txtValue := "123456789012" - client := setupTest(t, map[string]string{"example.org": "secret"}, handlerMock(addAction, txtValue)) + client := servermock.NewBuilder[*Client](setupClient(map[string]string{"example.org": "secret"})). + Route("POST /", + servermock.RawStringResponse(fmt.Sprintf("%s %s", successCode, txtValue)), + servermock.CheckQueryParameter().Strict(). + With("acme", addAction).With("txt", txtValue)). + Build(t) err := client.Add(t.Context(), "example.org", txtValue) require.NoError(t, err) @@ -37,16 +36,27 @@ func TestClient_Add(t *testing.T) { func TestClient_Add_error(t *testing.T) { txtValue := "123456789012" - client := setupTest(t, map[string]string{"example.com": "secret"}, handlerMock(addAction, txtValue)) + client := servermock.NewBuilder[*Client](setupClient(map[string]string{"example.com": "secret"})). + Route("POST /", + servermock.RawStringResponse(fmt.Sprintf("%s %s", successCode, txtValue)), + servermock.CheckQueryParameter().Strict(). + With("acme", addAction).With("txt", txtValue)). + Build(t) err := client.Add(t.Context(), "example.org", txtValue) - require.Error(t, err) + + require.EqualError(t, err, "domain example.org not found in credentials, check your credentials map") } func TestClient_Remove(t *testing.T) { txtValue := "ABCDEFGHIJKL" - client := setupTest(t, map[string]string{"example.org": "secret"}, handlerMock(removeAction, txtValue)) + client := servermock.NewBuilder[*Client](setupClient(map[string]string{"example.org": "secret"})). + Route("POST /", + servermock.RawStringResponse(fmt.Sprintf("%s %s", successCode, txtValue)), + servermock.CheckQueryParameter().Strict(). + With("acme", removeAction).With("txt", txtValue)). + Build(t) err := client.Remove(t.Context(), "example.org", txtValue) require.NoError(t, err) @@ -55,34 +65,45 @@ func TestClient_Remove(t *testing.T) { func TestClient_Remove_error(t *testing.T) { txtValue := "ABCDEFGHIJKL" - client := setupTest(t, map[string]string{"example.com": "secret"}, handlerMock(removeAction, txtValue)) + testCases := []struct { + desc string + hostname string + response string + expected string + }{ + { + desc: "response error - txt", + hostname: "example.com", + response: "error - no valid acme txt record", + expected: "error - no valid acme txt record", + }, + { + desc: "response error - acme", + hostname: "example.com", + response: "nochg 1234:1234:1234:1234:1234:1234:1234:1234", + expected: "nochg 1234:1234:1234:1234:1234:1234:1234:1234", + }, + { + desc: "credential error", + hostname: "example.org", + response: fmt.Sprintf("%s %s", successCode, txtValue), + expected: "domain example.org not found in credentials, check your credentials map", + }, + } - err := client.Remove(t.Context(), "example.org", txtValue) - require.Error(t, err) -} + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() -func handlerMock(action, value string) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - rw.WriteHeader(http.StatusOK) + client := servermock.NewBuilder[*Client](setupClient(map[string]string{"example.com": "secret"})). + Route("POST /", + servermock.RawStringResponse(test.response), + servermock.CheckQueryParameter().Strict(). + With("acme", removeAction).With("txt", txtValue)). + Build(t) - query := req.URL.Query() - - if query.Get("acme") != action { - _, _ = rw.Write([]byte("nochg 1234:1234:1234:1234:1234:1234:1234:1234")) - return - } - - txtValue := query.Get("txt") - if len(txtValue) < 12 { - _, _ = rw.Write([]byte("error - no valid acme txt record")) - return - } - - if txtValue != value { - http.Error(rw, fmt.Sprintf("got: %q, expected: %q", txtValue, value), http.StatusBadRequest) - return - } - - _, _ = fmt.Fprintf(rw, "%s %s", successCode, txtValue) + err := client.Remove(t.Context(), test.hostname, txtValue) + require.EqualError(t, err, test.expected) + }) } } diff --git a/providers/dns/dnshomede/internal/readme.md b/providers/dns/dnshomede/internal/readme.md index 014b062a1..622c4354d 100644 --- a/providers/dns/dnshomede/internal/readme.md +++ b/providers/dns/dnshomede/internal/readme.md @@ -16,7 +16,7 @@ Always returns StatusOK (200) If the API call works the first word of the response body is `successfully`. -If an error encoured the response body is `error - `. +If an error occurs the response body is `error - `. Can be a POST or a GET. @@ -35,6 +35,6 @@ Always returns StatusOK (200) If the API call works the first word of the response body is `successfully`. -If an error encoured the response body is `error - `. +If an error occurs the response body is `error - `. Can be a POST or a GET. diff --git a/providers/dns/dnsmadeeasy/internal/client.go b/providers/dns/dnsmadeeasy/internal/client.go index 491d5fd98..cb6f9d2cb 100644 --- a/providers/dns/dnsmadeeasy/internal/client.go +++ b/providers/dns/dnsmadeeasy/internal/client.go @@ -15,6 +15,7 @@ import ( "strconv" "time" + "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) @@ -57,10 +58,8 @@ func NewClient(apiKey, apiSecret string) (*Client, error) { func (c *Client) GetDomain(ctx context.Context, authZone string) (*Domain, error) { endpoint := c.BaseURL.JoinPath("dns", "managed", "name") - domainName := authZone[0 : len(authZone)-1] - query := endpoint.Query() - query.Set("domainname", domainName) + query.Set("domainname", dns01.UnFqdn(authZone)) endpoint.RawQuery = query.Encode() req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) diff --git a/providers/dns/dnsmadeeasy/internal/client_test.go b/providers/dns/dnsmadeeasy/internal/client_test.go index 721214693..f302c8d9b 100644 --- a/providers/dns/dnsmadeeasy/internal/client_test.go +++ b/providers/dns/dnsmadeeasy/internal/client_test.go @@ -2,14 +2,132 @@ package internal import ( "net/http" + "net/http/httptest" + "net/url" "testing" "time" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func Test_sign(t *testing.T) { +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient("key", "secret") + if err != nil { + return nil, err + } + + client.BaseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() + + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + With("x-dnsme-apiKey", "key"). + WithRegexp("x-dnsme-requestDate", `\w+, \d+ \w+ \d+ \d+:\d+:\d+ UTC`). + WithRegexp("x-dnsme-hmac", `[a-z0-9]+`), + ) +} + +func TestClient_GetDomain(t *testing.T) { + client := mockBuilder(). + Route("GET /dns/managed/name", + servermock.RawStringResponse(`{"id": 1, "name": "foo"}`), + servermock.CheckQueryParameter().Strict(). + With("domainname", "example.com")). + Build(t) + + domain, err := client.GetDomain(t.Context(), "example.com.") + require.NoError(t, err) + + expected := &Domain{ + ID: 1, + Name: "foo", + } + + assert.Equal(t, expected, domain) +} + +func TestClient_GetRecords(t *testing.T) { + client := mockBuilder(). + Route("GET /dns/managed/1/records", + servermock.ResponseFromFixture("get_records.json"), + servermock.CheckQueryParameter().Strict(). + With("recordName", "foo"). + With("type", "TXT"), + ). + Build(t) + + domain := &Domain{ID: 1, Name: "foo"} + + records, err := client.GetRecords(t.Context(), domain, "foo", "TXT") + require.NoError(t, err) + + expected := []Record{ + { + ID: 1, + Type: "TXT", + Name: "foo", + Value: "aaa", + TTL: 60, + SourceID: 123, + }, + { + ID: 2, + Type: "TXT", + Name: "bar", + Value: "bbb", + TTL: 120, + SourceID: 456, + }, + } + + assert.Equal(t, &expected, records) +} + +func TestClient_CreateRecord(t *testing.T) { + client := mockBuilder(). + Route("POST /dns/managed/1/records", nil, + servermock.CheckRequestJSONBodyFromFile("create_record-request.json")). + Build(t) + + domain := &Domain{ID: 1, Name: "foo"} + + record := &Record{ + ID: 1, + Type: "TXT", + Name: "foo", + Value: "aaa", + TTL: 60, + SourceID: 123, + } + + err := client.CreateRecord(t.Context(), domain, record) + require.NoError(t, err) +} + +func TestClient_DeleteRecord(t *testing.T) { + client := mockBuilder(). + Route("DELETE /dns/managed/123/records/1", nil). + Build(t) + + record := Record{ + ID: 1, + Type: "TXT", + Name: "foo", + Value: "aaa", + TTL: 60, + SourceID: 123, + } + + err := client.DeleteRecord(t.Context(), record) + require.NoError(t, err) +} + +func TestClient_sign(t *testing.T) { apiKey := "key" client := Client{apiKey: apiKey, apiSecret: "secret"} diff --git a/providers/dns/dnsmadeeasy/internal/fixtures/create_record-request.json b/providers/dns/dnsmadeeasy/internal/fixtures/create_record-request.json new file mode 100644 index 000000000..9a08b6544 --- /dev/null +++ b/providers/dns/dnsmadeeasy/internal/fixtures/create_record-request.json @@ -0,0 +1,8 @@ +{ + "id": 1, + "type": "TXT", + "name": "foo", + "value": "aaa", + "ttl": 60, + "sourceId": 123 +} diff --git a/providers/dns/dnsmadeeasy/internal/fixtures/get_records.json b/providers/dns/dnsmadeeasy/internal/fixtures/get_records.json new file mode 100644 index 000000000..5667e5e1d --- /dev/null +++ b/providers/dns/dnsmadeeasy/internal/fixtures/get_records.json @@ -0,0 +1,20 @@ +{ + "data": [ + { + "id": 1, + "type": "TXT", + "name": "foo", + "value": "aaa", + "ttl": 60, + "sourceId": 123 + }, + { + "id": 2, + "type": "TXT", + "name": "bar", + "value": "bbb", + "ttl": 120, + "sourceId": 456 + } + ] +} diff --git a/providers/dns/dode/internal/client_test.go b/providers/dns/dode/internal/client_test.go index 139a0939a..6fbaa8c1d 100644 --- a/providers/dns/dode/internal/client_test.go +++ b/providers/dns/dode/internal/client_test.go @@ -1,91 +1,43 @@ package internal import ( - "fmt" - "io" - "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, method, pattern string, status int, file string) *Client { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusBadRequest) - return - } - - query := req.URL.Query() - if query.Get("token") != "secret" { - http.Error(rw, fmt.Sprintf("invalid credentials: %q", query.Get("token")), http.StatusUnauthorized) - return - } - - if query.Get("domain") != "example.com" { - http.Error(rw, fmt.Sprintf("invalid domain: %q", query.Get("domain")), http.StatusBadRequest) - return - } - - if query.Has("action") { - if query.Get("action") != "delete" { - http.Error(rw, fmt.Sprintf("invalid action: %q", query.Get("action")), http.StatusBadRequest) - return - } - } else { - if query.Get("value") != "value" { - http.Error(rw, fmt.Sprintf("invalid value: %q", query.Get("value")), http.StatusBadRequest) - return - } - } - - if file == "" { - rw.WriteHeader(status) - return - } - - open, err := os.Open(filepath.Join("fixtures", file)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = open.Close() }() - - rw.WriteHeader(status) - _, err = io.Copy(rw, open) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - +func setupClient(server *httptest.Server) (*Client, error) { client := NewClient("secret") client.HTTPClient = server.Client() client.baseURL, _ = url.Parse(server.URL) - return client + return client, nil } func TestClient_UpdateTxtRecord(t *testing.T) { - client := setupTest(t, http.MethodGet, "/letsencrypt", http.StatusOK, "success.json") + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /letsencrypt", servermock.ResponseFromFixture("success.json"), + servermock.CheckQueryParameter().Strict(). + With("domain", "example.com"). + With("token", "secret"). + With("value", "value")). + Build(t) err := client.UpdateTxtRecord(t.Context(), "example.com.", "value", false) require.NoError(t, err) } func TestClient_UpdateTxtRecord_clear(t *testing.T) { - client := setupTest(t, http.MethodGet, "/letsencrypt", http.StatusOK, "success.json") + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /letsencrypt", servermock.ResponseFromFixture("success.json"), + servermock.CheckQueryParameter().Strict(). + With("action", "delete"). + With("domain", "example.com"). + With("token", "secret")). + Build(t) err := client.UpdateTxtRecord(t.Context(), "example.com.", "value", true) require.NoError(t, err) diff --git a/providers/dns/domeneshop/internal/client_test.go b/providers/dns/domeneshop/internal/client_test.go index 1f4265d03..beddc1cb2 100644 --- a/providers/dns/domeneshop/internal/client_test.go +++ b/providers/dns/domeneshop/internal/client_test.go @@ -1,121 +1,56 @@ package internal import ( - "net/http" "net/http/httptest" "net/url" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -const authorizationHeader = "Authorization" +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("token", "secret") + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := NewClient("token", "secret") - client.HTTPClient = server.Client() - client.baseURL, _ = url.Parse(server.URL) - - return client, mux + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithBasicAuth("token", "secret"), + ) } func TestClient_CreateTXTRecord(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("POST /domains/1/dns", + servermock.ResponseFromFixture("create_record.json"), + servermock.CheckRequestJSONBodyFromFile("create_record-request.json")). + Build(t) - mux.HandleFunc("/domains/1/dns", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - auth := req.Header.Get(authorizationHeader) - if auth != "Basic dG9rZW46c2VjcmV0" { - http.Error(rw, "invalid credentials: "+auth, http.StatusUnauthorized) - return - } - - _, _ = rw.Write([]byte(`{"id": 1}`)) - }) - - err := client.CreateTXTRecord(t.Context(), &Domain{ID: 1}, "example", "txtTXTtxt") + err := client.CreateTXTRecord(t.Context(), &Domain{ID: 1}, "example.com", "txtTXTtxt") require.NoError(t, err) } func TestClient_DeleteTXTRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/domains/1/dns", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - auth := req.Header.Get(authorizationHeader) - if auth != "Basic dG9rZW46c2VjcmV0" { - http.Error(rw, "invalid credentials: "+auth, http.StatusUnauthorized) - return - } - - _, _ = rw.Write([]byte(`[ - { - "id": 1, - "host": "example.com", - "ttl": 3600, - "type": "TXT", - "data": "txtTXTtxt" - } -]`)) - }) - - mux.HandleFunc("/domains/1/dns/1", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodDelete { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - auth := req.Header.Get(authorizationHeader) - if auth != "Basic dG9rZW46c2VjcmV0" { - http.Error(rw, "invalid credentials: "+auth, http.StatusUnauthorized) - return - } - }) + client := mockBuilder(). + Route("GET /domains/1/dns", + servermock.ResponseFromFixture("delete_record.json")). + Route("DELETE /domains/1/dns/1", nil). + Build(t) err := client.DeleteTXTRecord(t.Context(), &Domain{ID: 1}, "example.com", "txtTXTtxt") require.NoError(t, err) } func TestClient_getDNSRecordByHostData(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/domains/1/dns", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - auth := req.Header.Get(authorizationHeader) - if auth != "Basic dG9rZW46c2VjcmV0" { - http.Error(rw, "invalid credentials: "+auth, http.StatusUnauthorized) - return - } - - _, _ = rw.Write([]byte(`[ - { - "id": 1, - "host": "example.com", - "ttl": 3600, - "type": "TXT", - "data": "txtTXTtxt" - } -]`)) - }) + client := mockBuilder(). + Route("GET /domains/1/dns", + servermock.ResponseFromFixture("getDnsRecords.json")). + Build(t) record, err := client.getDNSRecordByHostData(t.Context(), Domain{ID: 1}, "example.com", "txtTXTtxt") require.NoError(t, err) @@ -132,43 +67,10 @@ func TestClient_getDNSRecordByHostData(t *testing.T) { } func TestClient_GetDomainByName(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/domains", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - auth := req.Header.Get(authorizationHeader) - if auth != "Basic dG9rZW46c2VjcmV0" { - http.Error(rw, "invalid credentials: "+auth, http.StatusUnauthorized) - return - } - - _, _ = rw.Write([]byte(`[ - { - "id": 1, - "domain": "example.com", - "expiry_date": "2019-08-24", - "registered_date": "2019-08-24", - "renew": true, - "registrant": "Ola Nordmann", - "status": "active", - "nameservers": [ - "ns1.hyp.net", - "ns2.hyp.net", - "ns3.hyp.net" - ], - "services": { - "registrar": true, - "dns": true, - "email": true, - "webhotel": "none" - } - } -]`)) - }) + client := mockBuilder(). + Route("GET /domains/", + servermock.ResponseFromFixture("getDomains.json")). + Build(t) domain, err := client.GetDomainByName(t.Context(), "example.com") require.NoError(t, err) diff --git a/providers/dns/domeneshop/internal/fixtures/create_record-request.json b/providers/dns/domeneshop/internal/fixtures/create_record-request.json new file mode 100644 index 000000000..6bd3ca4ce --- /dev/null +++ b/providers/dns/domeneshop/internal/fixtures/create_record-request.json @@ -0,0 +1,7 @@ +{ + "data": "txtTXTtxt", + "host": "example.com", + "id": 0, + "ttl": 300, + "type": "TXT" +} diff --git a/providers/dns/domeneshop/internal/fixtures/create_record.json b/providers/dns/domeneshop/internal/fixtures/create_record.json new file mode 100644 index 000000000..2572ae5fe --- /dev/null +++ b/providers/dns/domeneshop/internal/fixtures/create_record.json @@ -0,0 +1,3 @@ +{ + "id": 1 +} diff --git a/providers/dns/domeneshop/internal/fixtures/delete_record.json b/providers/dns/domeneshop/internal/fixtures/delete_record.json new file mode 100644 index 000000000..f3f987eef --- /dev/null +++ b/providers/dns/domeneshop/internal/fixtures/delete_record.json @@ -0,0 +1,9 @@ +[ + { + "id": 1, + "host": "example.com", + "ttl": 3600, + "type": "TXT", + "data": "txtTXTtxt" + } +] diff --git a/providers/dns/domeneshop/internal/fixtures/getDnsRecords.json b/providers/dns/domeneshop/internal/fixtures/getDnsRecords.json new file mode 100644 index 000000000..f3f987eef --- /dev/null +++ b/providers/dns/domeneshop/internal/fixtures/getDnsRecords.json @@ -0,0 +1,9 @@ +[ + { + "id": 1, + "host": "example.com", + "ttl": 3600, + "type": "TXT", + "data": "txtTXTtxt" + } +] diff --git a/providers/dns/domeneshop/internal/fixtures/getDomains.json b/providers/dns/domeneshop/internal/fixtures/getDomains.json new file mode 100644 index 000000000..b491d7f53 --- /dev/null +++ b/providers/dns/domeneshop/internal/fixtures/getDomains.json @@ -0,0 +1,22 @@ +[ + { + "id": 1, + "domain": "example.com", + "expiry_date": "2019-08-24", + "registered_date": "2019-08-24", + "renew": true, + "registrant": "Ola Nordmann", + "status": "active", + "nameservers": [ + "ns1.hyp.net", + "ns2.hyp.net", + "ns3.hyp.net" + ], + "services": { + "registrar": true, + "dns": true, + "email": true, + "webhotel": "none" + } + } +] diff --git a/providers/dns/dreamhost/dreamhost_test.go b/providers/dns/dreamhost/dreamhost_test.go index 0f91ffae2..f85e00da4 100644 --- a/providers/dns/dreamhost/dreamhost_test.go +++ b/providers/dns/dreamhost/dreamhost_test.go @@ -1,13 +1,12 @@ package dreamhost import ( - "fmt" - "net/http" "net/http/httptest" "testing" "time" "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -23,22 +22,15 @@ const ( fakeKeyAuth = "w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI" ) -func setupTest(t *testing.T) (*DNSProvider, *http.ServeMux) { - t.Helper() +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder(func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.APIKey = fakeAPIKey + config.BaseURL = server.URL + config.HTTPClient = server.Client() - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - config := NewDefaultConfig() - config.APIKey = fakeAPIKey - config.BaseURL = server.URL - config.HTTPClient = server.Client() - - provider, err := NewDNSProviderConfig(config) - require.NoError(t, err) - - return provider, mux + return NewDNSProviderConfig(config) + }) } func TestNewDNSProvider(t *testing.T) { @@ -115,67 +107,48 @@ func TestNewDNSProviderConfig(t *testing.T) { } func TestDNSProvider_Present(t *testing.T) { - provider, mux := setupTest(t) - - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodGet, r.Method, "method") - - q := r.URL.Query() - assert.Equal(t, fakeAPIKey, q.Get("key")) - assert.Equal(t, "dns-add_record", q.Get("cmd")) - assert.Equal(t, "json", q.Get("format")) - assert.Equal(t, "_acme-challenge.example.com", q.Get("record")) - assert.Equal(t, fakeKeyAuth, q.Get("value")) - assert.Equal(t, "Managed+By+lego", q.Get("comment")) - - _, err := fmt.Fprintf(w, `{"data":"record_added","result":"success"}`) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) + provider := mockBuilder(). + Route("GET /", + servermock.RawStringResponse(`{"data":"record_added","result":"success"}`), + servermock.CheckQueryParameter().Strict(). + With("cmd", "dns-add_record"). + With("comment", "Managed+By+lego"). + With("format", "json"). + With("record", "_acme-challenge.example.com"). + With("type", "TXT"). + With("key", fakeAPIKey). + With("value", fakeKeyAuth), + ). + Build(t) err := provider.Present("example.com", "", fakeChallengeToken) require.NoError(t, err) } func TestDNSProvider_PresentFailed(t *testing.T) { - provider, mux := setupTest(t) - - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodGet, r.Method, "method") - - _, err := fmt.Fprintf(w, `{"data":"record_already_exists_remove_first","result":"error"}`) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) + provider := mockBuilder(). + Route("GET /", + servermock.RawStringResponse(`{"data":"record_already_exists_remove_first","result":"error"}`)). + Build(t) err := provider.Present("example.com", "", fakeChallengeToken) require.EqualError(t, err, "dreamhost: add TXT record failed: record_already_exists_remove_first") } func TestDNSProvider_Cleanup(t *testing.T) { - provider, mux := setupTest(t) - - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodGet, r.Method, "method") - - q := r.URL.Query() - assert.Equal(t, fakeAPIKey, q.Get("key"), "key mismatch") - assert.Equal(t, "dns-remove_record", q.Get("cmd"), "cmd mismatch") - assert.Equal(t, "json", q.Get("format")) - assert.Equal(t, "_acme-challenge.example.com", q.Get("record")) - assert.Equal(t, fakeKeyAuth, q.Get("value"), "value mismatch") - assert.Equal(t, "Managed+By+lego", q.Get("comment")) - - _, err := fmt.Fprintf(w, `{"data":"record_removed","result":"success"}`) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) + provider := mockBuilder(). + Route("GET /", + servermock.RawStringResponse(`{"data":"record_removed","result":"success"}`), + servermock.CheckQueryParameter().Strict(). + With("cmd", "dns-remove_record"). + With("comment", "Managed+By+lego"). + With("format", "json"). + With("record", "_acme-challenge.example.com"). + With("type", "TXT"). + With("key", fakeAPIKey). + With("value", fakeKeyAuth), + ). + Build(t) err := provider.CleanUp("example.com", "", fakeChallengeToken) require.NoError(t, err, "failed to remove TXT record") diff --git a/providers/dns/dreamhost/internal/client_test.go b/providers/dns/dreamhost/internal/client_test.go index eff520df0..a836658f9 100644 --- a/providers/dns/dreamhost/internal/client_test.go +++ b/providers/dns/dreamhost/internal/client_test.go @@ -1,15 +1,59 @@ package internal import ( + "net/http/httptest" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -const fakeAPIKey = "asdf1234" +func setupClient(server *httptest.Server) (*Client, error) { + client := NewClient("secret") + client.BaseURL = server.URL + client.HTTPClient = server.Client() + + return client, nil +} + +func TestClient_AddRecord(t *testing.T) { + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /", servermock.RawStringResponse(`{}`), + servermock.CheckQueryParameter().Strict(). + With("cmd", "dns-add_record"). + With("comment", "Managed+By+lego"). + With("format", "json"). + With("key", "secret"). + With("record", "example.com"). + With("type", "TXT"). + With("value", "aaa")). + Build(t) + + err := client.AddRecord(t.Context(), "example.com", "aaa") + require.NoError(t, err) +} + +func TestClient_RemoveRecord(t *testing.T) { + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /", servermock.RawStringResponse(`{}`), + servermock.CheckQueryParameter().Strict(). + With("cmd", "dns-remove_record"). + With("comment", "Managed+By+lego"). + With("format", "json"). + With("key", "secret"). + With("record", "example.com"). + With("type", "TXT"). + With("value", "aaa")). + Build(t) + + err := client.RemoveRecord(t.Context(), "example.com", "aaa") + require.NoError(t, err) +} func TestClient_buildQuery(t *testing.T) { + const fakeAPIKey = "asdf1234" + testCases := []struct { desc string apiKey string diff --git a/providers/dns/duckdns/internal/client.go b/providers/dns/duckdns/internal/client.go index 0ed1bc864..ae86f64c8 100644 --- a/providers/dns/duckdns/internal/client.go +++ b/providers/dns/duckdns/internal/client.go @@ -21,6 +21,7 @@ const defaultBaseURL = "https://www.duckdns.org/update" type Client struct { token string + baseURL string HTTPClient *http.Client } @@ -28,6 +29,7 @@ type Client struct { func NewClient(token string) *Client { return &Client{ token: token, + baseURL: defaultBaseURL, HTTPClient: &http.Client{Timeout: 5 * time.Second}, } } @@ -44,7 +46,7 @@ func (c Client) RemoveTXTRecord(ctx context.Context, domain string) error { // To update the TXT record we just need to make one simple get request. // In DuckDNS you only have one TXT record shared with the domain and all subdomains. func (c Client) UpdateTxtRecord(ctx context.Context, domain, txt string, clearRecord bool) error { - endpoint, _ := url.Parse(defaultBaseURL) + endpoint, _ := url.Parse(c.baseURL) mainDomain := getMainDomain(domain) if mainDomain == "" { diff --git a/providers/dns/duckdns/internal/client_test.go b/providers/dns/duckdns/internal/client_test.go index 4df17d049..aaa441fad 100644 --- a/providers/dns/duckdns/internal/client_test.go +++ b/providers/dns/duckdns/internal/client_test.go @@ -1,11 +1,50 @@ package internal import ( + "net/http/httptest" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) +func setupClient(server *httptest.Server) (*Client, error) { + client := NewClient("secret") + client.baseURL = server.URL + client.HTTPClient = server.Client() + + return client, nil +} + +func TestClient_AddTXTRecord(t *testing.T) { + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /", servermock.RawStringResponse("OK"), + servermock.CheckQueryParameter().Strict(). + With("clear", "false"). + With("domains", "com"). + With("token", "secret"). + With("txt", "value")). + Build(t) + + err := client.AddTXTRecord(t.Context(), "example.com", "value") + require.NoError(t, err) +} + +func TestClient_RemoveTXTRecord(t *testing.T) { + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /", servermock.RawStringResponse("OK"), + servermock.CheckQueryParameter().Strict(). + With("clear", "true"). + With("domains", "com"). + With("token", "secret"). + With("txt", "")). + Build(t) + + err := client.RemoveTXTRecord(t.Context(), "example.com") + require.NoError(t, err) +} + func Test_getMainDomain(t *testing.T) { testCases := []struct { desc string diff --git a/providers/dns/dyn/internal/client_test.go b/providers/dns/dyn/internal/client_test.go index c6cdff9d5..f166e7d8d 100644 --- a/providers/dns/dyn/internal/client_test.go +++ b/providers/dns/dyn/internal/client_test.go @@ -1,120 +1,58 @@ package internal import ( - "fmt" - "io" - "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, pattern string, handlerFunc http.HandlerFunc) *Client { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc(pattern, handlerFunc) - +func setupClient(server *httptest.Server) (*Client, error) { client := NewClient("bob", "user", "secret") client.HTTPClient = server.Client() client.baseURL, _ = url.Parse(server.URL) - return client + return client, nil } -func authenticatedHandler(method string, status int, file string) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusBadRequest) - return - } +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("bob", "user", "secret") + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) - token := req.Header.Get(authTokenHeader) - if token != "tok" { - http.Error(rw, fmt.Sprintf("invalid credentials: %q", token), http.StatusUnauthorized) - return - } - - if file == "" { - rw.WriteHeader(status) - return - } - - open, err := os.Open(filepath.Join("fixtures", file)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = open.Close() }() - - rw.WriteHeader(status) - _, err = io.Copy(rw, open) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - } -} - -func unauthenticatedHandler(method string, status int, file string) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusBadRequest) - return - } - - token := req.Header.Get(authTokenHeader) - if token != "" { - http.Error(rw, fmt.Sprintf("invalid credentials: %q", token), http.StatusUnauthorized) - return - } - - if file == "" { - rw.WriteHeader(status) - return - } - - open, err := os.Open(filepath.Join("fixtures", file)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = open.Close() }() - - rw.WriteHeader(status) - _, err = io.Copy(rw, open) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - } + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders()) } func TestClient_Publish(t *testing.T) { - client := setupTest(t, "/Zone/example.com", unauthenticatedHandler(http.MethodPut, http.StatusOK, "publish.json")) + client := mockBuilder(). + Route("PUT /Zone/example.com", servermock.ResponseFromFixture("publish.json"), + servermock.CheckRequestJSONBody(`{"publish":true,"notes":"my message"}`)). + Build(t) err := client.Publish(t.Context(), "example.com", "my message") require.NoError(t, err) } func TestClient_AddTXTRecord(t *testing.T) { - client := setupTest(t, "/TXTRecord/example.com/example.com.", unauthenticatedHandler(http.MethodPost, http.StatusCreated, "create-txt-record.json")) + client := mockBuilder(). + Route("POST /TXTRecord/example.com/example.com.", servermock.ResponseFromFixture("create-txt-record.json"), + servermock.CheckRequestJSONBody(`{"rdata":{"txtdata":"txt"},"ttl":"120"}`)). + Build(t) err := client.AddTXTRecord(t.Context(), "example.com", "example.com.", "txt", 120) require.NoError(t, err) } func TestClient_RemoveTXTRecord(t *testing.T) { - client := setupTest(t, "/TXTRecord/example.com/example.com.", unauthenticatedHandler(http.MethodDelete, http.StatusOK, "")) + client := mockBuilder(). + Route("DELETE /TXTRecord/example.com/example.com.", nil). + Build(t) err := client.RemoveTXTRecord(t.Context(), "example.com", "example.com.") require.NoError(t, err) diff --git a/providers/dns/dyn/internal/session_test.go b/providers/dns/dyn/internal/session_test.go index 5a939f40c..349b1b190 100644 --- a/providers/dns/dyn/internal/session_test.go +++ b/providers/dns/dyn/internal/session_test.go @@ -2,9 +2,9 @@ package internal import ( "context" - "net/http" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -16,7 +16,10 @@ func mockContext(t *testing.T) context.Context { } func TestClient_login(t *testing.T) { - client := setupTest(t, "/Session", unauthenticatedHandler(http.MethodPost, http.StatusOK, "login.json")) + client := mockBuilder(). + Route("POST /Session", servermock.ResponseFromFixture("login.json"), + servermock.CheckRequestJSONBody(`{"customer_name":"bob","user_name":"user","password":"secret"}`)). + Build(t) sess, err := client.login(t.Context()) require.NoError(t, err) @@ -27,14 +30,22 @@ func TestClient_login(t *testing.T) { } func TestClient_Logout(t *testing.T) { - client := setupTest(t, "/Session", authenticatedHandler(http.MethodDelete, http.StatusOK, "")) + client := servermock.NewBuilder[*Client](setupClient, + servermock.CheckHeader().WithJSONHeaders(). + With(authTokenHeader, "tok"), + ). + Route("DELETE /Session", nil). + Build(t) err := client.Logout(mockContext(t)) require.NoError(t, err) } func TestClient_CreateAuthenticatedContext(t *testing.T) { - client := setupTest(t, "/Session", unauthenticatedHandler(http.MethodPost, http.StatusOK, "login.json")) + client := mockBuilder(). + Route("POST /Session", servermock.ResponseFromFixture("login.json"), + servermock.CheckRequestJSONBody(`{"customer_name":"bob","user_name":"user","password":"secret"}`)). + Build(t) ctx, err := client.CreateAuthenticatedContext(t.Context()) require.NoError(t, err) diff --git a/providers/dns/dyndnsfree/internal/client_test.go b/providers/dns/dyndnsfree/internal/client_test.go index 206022d5c..d6f1d276b 100644 --- a/providers/dns/dyndnsfree/internal/client_test.go +++ b/providers/dns/dyndnsfree/internal/client_test.go @@ -1,56 +1,44 @@ package internal import ( - "net/http" "net/http/httptest" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, message string) *Client { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - +func setupClient(server *httptest.Server) (*Client, error) { client, err := NewClient("user", "secret") - require.NoError(t, err) + if err != nil { + return nil, err + } - client.HTTPClient = server.Client() client.baseURL = server.URL + client.HTTPClient = server.Client() - mux.HandleFunc("GET /", func(rw http.ResponseWriter, req *http.Request) { - query := req.URL.Query() - - username := query.Get("username") - if username != "user" { - http.Error(rw, "invalid username: "+username, http.StatusUnauthorized) - return - } - - password := query.Get("password") - if password != "secret" { - http.Error(rw, "invalid password: "+password, http.StatusUnauthorized) - return - } - - _, _ = rw.Write([]byte(message)) - }) - - return client + return client, nil } func TestAddTXTRecord(t *testing.T) { - client := setupTest(t, "success") + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /", servermock.RawStringResponse("success"), + servermock.CheckQueryParameter().Strict(). + With("add_hostname", "sub.example.com"). + With("hostname", "example.com"). + With("password", "secret"). + With("txt", "value"). + With("username", "user")). + Build(t) err := client.AddTXTRecord(t.Context(), "example.com", "sub.example.com", "value") require.NoError(t, err) } func TestAddTXTRecord_error(t *testing.T) { - client := setupTest(t, "error: authentification failed") + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /", servermock.RawStringResponse("error: authentification failed")). + Build(t) err := client.AddTXTRecord(t.Context(), "example.com", "sub.example.com", "value") require.EqualError(t, err, "error: authentification failed") diff --git a/providers/dns/dynu/internal/client_test.go b/providers/dns/dynu/internal/client_test.go index 4f3a16be9..7dc94eca2 100644 --- a/providers/dns/dynu/internal/client_test.go +++ b/providers/dns/dynu/internal/client_test.go @@ -1,52 +1,27 @@ package internal import ( - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, method, pattern string, status int, file string) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient() + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - open, err := os.Open(file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = open.Close() }() - - rw.WriteHeader(status) - _, err = io.Copy(rw, open) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - client := NewClient() - client.HTTPClient = server.Client() - client.baseURL, _ = url.Parse(server.URL) - - return client + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(), + ) } func TestGetRootDomain(t *testing.T) { @@ -64,9 +39,9 @@ func TestGetRootDomain(t *testing.T) { }{ { desc: "success", - pattern: "/dns/getroot/test.lego.freeddns.org", + pattern: "GET /dns/getroot/test.lego.freeddns.org", status: http.StatusOK, - file: "./fixtures/get_root_domain.json", + file: "get_root_domain.json", expected: expected{ domain: &DNSHostname{ APIException: &APIException{ @@ -81,9 +56,9 @@ func TestGetRootDomain(t *testing.T) { }, { desc: "invalid", - pattern: "/dns/getroot/test.lego.freeddns.org", + pattern: "GET /dns/getroot/test.lego.freeddns.org", status: http.StatusNotImplemented, - file: "./fixtures/get_root_domain_invalid.json", + file: "get_root_domain_invalid.json", expected: expected{ error: "API error: 501: Argument Exception: Invalid.", }, @@ -94,7 +69,9 @@ func TestGetRootDomain(t *testing.T) { t.Run(test.desc, func(t *testing.T) { t.Parallel() - client := setupTest(t, http.MethodGet, test.pattern, test.status, test.file) + client := mockBuilder(). + Route(test.pattern, servermock.ResponseFromFixture(test.file).WithStatusCode(test.status)). + Build(t) domain, err := client.GetRootDomain(t.Context(), "test.lego.freeddns.org") @@ -126,9 +103,9 @@ func TestGetRecords(t *testing.T) { }{ { desc: "success", - pattern: "/dns/record/_acme-challenge.lego.freeddns.org", + pattern: "GET /dns/record/_acme-challenge.lego.freeddns.org", status: http.StatusOK, - file: "./fixtures/get_records.json", + file: "get_records.json", expected: expected{ records: []DNSRecord{ { @@ -160,18 +137,18 @@ func TestGetRecords(t *testing.T) { }, { desc: "empty", - pattern: "/dns/record/_acme-challenge.lego.freeddns.org", + pattern: "GET /dns/record/_acme-challenge.lego.freeddns.org", status: http.StatusOK, - file: "./fixtures/get_records_empty.json", + file: "get_records_empty.json", expected: expected{ records: []DNSRecord{}, }, }, { desc: "invalid", - pattern: "/dns/record/_acme-challenge.lego.freeddns.org", + pattern: "GET /dns/record/_acme-challenge.lego.freeddns.org", status: http.StatusNotImplemented, - file: "./fixtures/get_records_invalid.json", + file: "get_records_invalid.json", expected: expected{ error: "API error: 501: Argument Exception: Invalid.", }, @@ -182,7 +159,11 @@ func TestGetRecords(t *testing.T) { t.Run(test.desc, func(t *testing.T) { t.Parallel() - client := setupTest(t, http.MethodGet, test.pattern, test.status, test.file) + client := mockBuilder(). + Route(test.pattern, servermock.ResponseFromFixture(test.file).WithStatusCode(test.status), + servermock.CheckQueryParameter().Strict(). + With("recordType", "TXT")). + Build(t) records, err := client.GetRecords(t.Context(), "_acme-challenge.lego.freeddns.org", "TXT") @@ -213,15 +194,15 @@ func TestAddNewRecord(t *testing.T) { }{ { desc: "success", - pattern: "/dns/9007481/record", + pattern: "POST /dns/9007481/record", status: http.StatusOK, - file: "./fixtures/add_new_record.json", + file: "add_new_record.json", }, { desc: "invalid", - pattern: "/dns/9007481/record", + pattern: "POST /dns/9007481/record", status: http.StatusNotImplemented, - file: "./fixtures/add_new_record_invalid.json", + file: "add_new_record_invalid.json", expected: expected{ error: "API error: 501: Argument Exception: Invalid.", }, @@ -232,7 +213,10 @@ func TestAddNewRecord(t *testing.T) { t.Run(test.desc, func(t *testing.T) { t.Parallel() - client := setupTest(t, http.MethodPost, test.pattern, test.status, test.file) + client := mockBuilder(). + Route(test.pattern, servermock.ResponseFromFixture(test.file).WithStatusCode(test.status), + servermock.CheckRequestJSONBodyFromFile("add_new_record-request.json")). + Build(t) record := DNSRecord{ Type: "TXT", @@ -270,15 +254,15 @@ func TestDeleteRecord(t *testing.T) { }{ { desc: "success", - pattern: "/", + pattern: "DELETE /", status: http.StatusOK, - file: "./fixtures/delete_record.json", + file: "delete_record.json", }, { desc: "invalid", - pattern: "/", + pattern: "DELETE /", status: http.StatusNotImplemented, - file: "./fixtures/delete_record_invalid.json", + file: "delete_record_invalid.json", expected: expected{ error: "API error: 501: Argument Exception: Invalid.", }, @@ -289,7 +273,9 @@ func TestDeleteRecord(t *testing.T) { t.Run(test.desc, func(t *testing.T) { t.Parallel() - client := setupTest(t, http.MethodDelete, test.pattern, test.status, test.file) + client := mockBuilder(). + Route(test.pattern, servermock.ResponseFromFixture(test.file).WithStatusCode(test.status)). + Build(t) err := client.DeleteRecord(t.Context(), 9007481, 6041418) diff --git a/providers/dns/dynu/internal/fixtures/add_new_record-request.json b/providers/dns/dynu/internal/fixtures/add_new_record-request.json new file mode 100644 index 000000000..f3c75ca36 --- /dev/null +++ b/providers/dns/dynu/internal/fixtures/add_new_record-request.json @@ -0,0 +1,9 @@ +{ + "recordType": "TXT", + "domainName": "lego.freeddns.org", + "nodeName": "_acme-challenge", + "hostname": "_acme-challenge.lego.freeddns.org", + "state": true, + "textData": "txt_txt_txt_txt_txt_txt_txt_2", + "ttl": 300 +} diff --git a/providers/dns/easydns/easydns_test.go b/providers/dns/easydns/easydns_test.go index 972ff8cda..9a11ef6cc 100644 --- a/providers/dns/easydns/easydns_test.go +++ b/providers/dns/easydns/easydns_test.go @@ -2,7 +2,6 @@ package easydns import ( "fmt" - "io" "net/http" "net/http/httptest" "net/url" @@ -10,12 +9,10 @@ import ( "time" "github.com/go-acme/lego/v4/platform/tester" - "github.com/stretchr/testify/assert" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) -const authorizationHeader = "Authorization" - const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest( @@ -24,26 +21,27 @@ var envTest = tester.NewEnvTest( EnvKey). WithDomain(envDomain) -func setupTest(t *testing.T) (*DNSProvider, *http.ServeMux) { - t.Helper() +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + endpoint, err := url.Parse(server.URL) + if err != nil { + return nil, err + } - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) + config := NewDefaultConfig() + config.Token = "TOKEN" + config.Key = "SECRET" + config.Endpoint = endpoint + config.HTTPClient = server.Client() - endpoint, err := url.Parse(server.URL) - require.NoError(t, err) - - config := NewDefaultConfig() - config.Token = "TOKEN" - config.Key = "SECRET" - config.Endpoint = endpoint - config.HTTPClient = server.Client() - - provider, err := NewDNSProviderConfig(config) - require.NoError(t, err) - - return provider, mux + return NewDNSProviderConfig(config) + }, + servermock.CheckHeader(). + WithJSONHeaders(). + WithAuthorization("Basic VE9LRU46U0VDUkVU"), + servermock.CheckQueryParameter().Strict(). + With("format", "json")) } func TestNewDNSProvider(t *testing.T) { @@ -145,78 +143,50 @@ func TestNewDNSProviderConfig(t *testing.T) { } func TestDNSProvider_Present(t *testing.T) { - provider, mux := setupTest(t) - - mux.HandleFunc("/zones/records/all/example.com", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodGet, r.Method, "method") - assert.Equal(t, "format=json", r.URL.RawQuery, "query") - assert.Equal(t, "Basic VE9LRU46U0VDUkVU", r.Header.Get(authorizationHeader), authorizationHeader) - - w.WriteHeader(http.StatusOK) - _, err := fmt.Fprintf(w, `{ - "msg": "string", - "status": 200, - "tm": 0, - "data": [{ - "id": "60898922", - "domain": "example.com", - "host": "hosta", - "ttl": "300", - "prio": "0", - "geozone_id": "0", - "type": "A", - "rdata": "1.2.3.4", - "last_mod": "2019-08-28 19:09:50" - }], - "count": 0, - "total": 0, - "start": 0, - "max": 0 -} -`) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return + provider := mockBuilder(). + Route("GET /zones/records/all/example.com", + servermock.RawStringResponse(`{ + "msg": "string", + "status": 200, + "tm": 0, + "data": [{ + "id": "60898922", + "domain": "example.com", + "host": "hosta", + "ttl": "300", + "prio": "0", + "geozone_id": "0", + "type": "A", + "rdata": "1.2.3.4", + "last_mod": "2019-08-28 19:09:50" + }], + "count": 0, + "total": 0, + "start": 0, + "max": 0 } - }) - - mux.HandleFunc("/zones/records/add/example.com/TXT", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodPut, r.Method, "method") - assert.Equal(t, "format=json", r.URL.RawQuery, "query") - assert.Equal(t, "application/json", r.Header.Get("Content-Type"), "Content-Type") - assert.Equal(t, "Basic VE9LRU46U0VDUkVU", r.Header.Get(authorizationHeader), authorizationHeader) - - reqBody, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - expectedReqBody := `{"domain":"example.com","host":"_acme-challenge","ttl":"120","prio":"0","type":"TXT","rdata":"pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM"} -` - assert.Equal(t, expectedReqBody, string(reqBody)) - - w.WriteHeader(http.StatusCreated) - _, err = fmt.Fprintf(w, `{ - "msg": "OK", - "tm": 1554681934, - "data": { - "host": "_acme-challenge", - "geozone_id": 0, - "ttl": "120", - "prio": "0", - "rdata": "pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM", - "revoked": 0, - "id": "123456789", - "new_host": "_acme-challenge.example.com" - }, - "status": 201 - }`) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) + `), + servermock.CheckQueryParameter().Strict(). + With("format", "json")). + Route("PUT /zones/records/add/example.com/TXT", + servermock.RawStringResponse(`{ + "msg": "OK", + "tm": 1554681934, + "data": { + "host": "_acme-challenge", + "geozone_id": 0, + "ttl": "120", + "prio": "0", + "rdata": "pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM", + "revoked": 0, + "id": "123456789", + "new_host": "_acme-challenge.example.com" + }, + "status": 201 + }`), + servermock.CheckRequestJSONBody(`{"domain":"example.com","host":"_acme-challenge","ttl":"120","prio":"0","type":"TXT","rdata":"pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM"} +`)). + Build(t) err := provider.Present("example.com", "token", "keyAuth") require.NoError(t, err) @@ -224,163 +194,116 @@ func TestDNSProvider_Present(t *testing.T) { } func TestDNSProvider_Cleanup_WhenRecordIdNotSet_NoOp(t *testing.T) { - provider, mux := setupTest(t) - - mux.HandleFunc("/zones/records/all/example.com", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodGet, r.Method, "method") - assert.Equal(t, "format=json", r.URL.RawQuery, "query") - assert.Equal(t, "Basic VE9LRU46U0VDUkVU", r.Header.Get(authorizationHeader), authorizationHeader) - - w.WriteHeader(http.StatusOK) - _, err := fmt.Fprintf(w, `{ - "msg": "string", - "status": 200, - "tm": 0, - "data": [{ - "id": "60898922", - "domain": "example.com", - "host": "hosta", - "ttl": "300", - "prio": "0", - "geozone_id": "0", - "type": "A", - "rdata": "1.2.3.4", - "last_mod": "2019-08-28 19:09:50" - }], - "count": 0, - "total": 0, - "start": 0, - "max": 0 -} -`) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) + provider := mockBuilder(). + Route("GET /zones/records/all/_acme-challenge.example.com", + servermock.RawStringResponse(`{ + "msg": "string", + "status": 200, + "tm": 0, + "data": [{ + "id": "60898922", + "domain": "example.com", + "host": "hosta", + "ttl": "300", + "prio": "0", + "geozone_id": "0", + "type": "A", + "rdata": "1.2.3.4", + "last_mod": "2019-08-28 19:09:50" + }], + "count": 0, + "total": 0, + "start": 0, + "max": 0 + } + `)). + Build(t) err := provider.CleanUp("example.com", "token", "keyAuth") require.NoError(t, err) } func TestDNSProvider_Cleanup_WhenRecordIdSet_DeletesTxtRecord(t *testing.T) { - provider, mux := setupTest(t) - - mux.HandleFunc("/zones/records/all/example.com", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodGet, r.Method, "method") - assert.Equal(t, "format=json", r.URL.RawQuery, "query") - assert.Equal(t, "Basic VE9LRU46U0VDUkVU", r.Header.Get(authorizationHeader), authorizationHeader) - - w.WriteHeader(http.StatusOK) - _, err := fmt.Fprintf(w, `{ - "msg": "string", - "status": 200, - "tm": 0, - "data": [{ - "id": "60898922", - "domain": "example.com", - "host": "hosta", - "ttl": "300", - "prio": "0", - "geozone_id": "0", - "type": "A", - "rdata": "1.2.3.4", - "last_mod": "2019-08-28 19:09:50" - }], - "count": 0, - "total": 0, - "start": 0, - "max": 0 -} -`) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) - - mux.HandleFunc("/zones/records/example.com/123456", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodDelete, r.Method, "method") - assert.Equal(t, "format=json", r.URL.RawQuery, "query") - assert.Equal(t, "Basic VE9LRU46U0VDUkVU", r.Header.Get(authorizationHeader), authorizationHeader) - - w.WriteHeader(http.StatusOK) - _, err := fmt.Fprintf(w, `{ - "msg": "OK", - "data": { - "domain": "example.com", - "id": "123456" - }, - "status": 200 - }`) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) + provider := mockBuilder(). + Route("GET /zones/records/all/_acme-challenge.example.com", + servermock.RawStringResponse(`{ + "msg": "string", + "status": 200, + "tm": 0, + "data": [{ + "id": "60898922", + "domain": "example.com", + "host": "hosta", + "ttl": "300", + "prio": "0", + "geozone_id": "0", + "type": "A", + "rdata": "1.2.3.4", + "last_mod": "2019-08-28 19:09:50" + }], + "count": 0, + "total": 0, + "start": 0, + "max": 0 + } + `)). + Route("DELETE /zones/records/_acme-challenge.example.com/123456", + servermock.RawStringResponse(`{ + "msg": "OK", + "data": { + "domain": "example.com", + "id": "123456" + }, + "status": 200 + }`)). + Build(t) provider.recordIDs["_acme-challenge.example.com.|pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM"] = "123456" + err := provider.CleanUp("example.com", "token", "keyAuth") require.NoError(t, err) } func TestDNSProvider_Cleanup_WhenHttpError_ReturnsError(t *testing.T) { - provider, mux := setupTest(t) - - mux.HandleFunc("/zones/records/all/example.com", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodGet, r.Method, "method") - assert.Equal(t, "format=json", r.URL.RawQuery, "query") - assert.Equal(t, "Basic VE9LRU46U0VDUkVU", r.Header.Get(authorizationHeader), authorizationHeader) - - w.WriteHeader(http.StatusOK) - _, err := fmt.Fprintf(w, `{ - "msg": "string", - "status": 200, - "tm": 0, - "data": [{ - "id": "60898922", - "domain": "example.com", - "host": "hosta", - "ttl": "300", - "prio": "0", - "geozone_id": "0", - "type": "A", - "rdata": "1.2.3.4", - "last_mod": "2019-08-28 19:09:50" - }], - "count": 0, - "total": 0, - "start": 0, - "max": 0 -} -`) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) - errorMessage := `{ "error": { "code": 406, "message": "Provided id is invalid or you do not have permission to access it." } }` - mux.HandleFunc("/zones/records/example.com/123456", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodDelete, r.Method, "method") - assert.Equal(t, "format=json", r.URL.RawQuery, "query") - assert.Equal(t, "Basic VE9LRU46U0VDUkVU", r.Header.Get(authorizationHeader), authorizationHeader) - w.WriteHeader(http.StatusNotAcceptable) - _, err := fmt.Fprint(w, errorMessage) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) + provider := mockBuilder(). + Route("GET /zones/records/all/example.com", + servermock.RawStringResponse(`{ + "msg": "string", + "status": 200, + "tm": 0, + "data": [{ + "id": "60898922", + "domain": "example.com", + "host": "hosta", + "ttl": "300", + "prio": "0", + "geozone_id": "0", + "type": "A", + "rdata": "1.2.3.4", + "last_mod": "2019-08-28 19:09:50" + }], + "count": 0, + "total": 0, + "start": 0, + "max": 0 +} +`)). + Route("DELETE /zones/records/example.com/123456", + servermock.RawStringResponse(errorMessage). + WithStatusCode(http.StatusNotAcceptable)). + Build(t) provider.recordIDs["_acme-challenge.example.com.|pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM"] = "123456" + err := provider.CleanUp("example.com", "token", "keyAuth") + expectedError := fmt.Sprintf("easydns: unexpected status code: [status code: 406] body: %v", errorMessage) require.EqualError(t, err, expectedError) } diff --git a/providers/dns/easydns/internal/client_test.go b/providers/dns/easydns/internal/client_test.go index 02d46a5a7..bf4e1e45b 100644 --- a/providers/dns/easydns/internal/client_test.go +++ b/providers/dns/easydns/internal/client_test.go @@ -1,73 +1,34 @@ package internal import ( - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, method, pattern string, status int, file string) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("tok", "k") + client.HTTPClient = server.Client() + client.BaseURL, _ = url.Parse(server.URL) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusBadRequest) - return - } - - token, key, ok := req.BasicAuth() - if token != "tok" || key != "k" || !ok { - http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return - } - - if req.URL.Query().Get("format") != "json" { - http.Error(rw, fmt.Sprintf("invalid format: %s", req.URL.Query().Get("format")), http.StatusBadRequest) - return - } - - if file == "" { - rw.WriteHeader(status) - return - } - - open, err := os.Open(filepath.Join("fixtures", file)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = open.Close() }() - - rw.WriteHeader(status) - _, err = io.Copy(rw, open) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - client := NewClient("tok", "k") - client.HTTPClient = server.Client() - client.BaseURL, _ = url.Parse(server.URL) - - return client + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithBasicAuth("tok", "k"), + ) } func TestClient_ListZones(t *testing.T) { - client := setupTest(t, http.MethodGet, "/zones/records/all/example.com", http.StatusOK, "list-zone.json") + client := mockBuilder(). + Route("GET /zones/records/all/example.com", servermock.ResponseFromFixture("list-zone.json")). + Build(t) zones, err := client.ListZones(t.Context(), "example.com") require.NoError(t, err) @@ -87,14 +48,20 @@ func TestClient_ListZones(t *testing.T) { } func TestClient_ListZones_error(t *testing.T) { - client := setupTest(t, http.MethodGet, "/zones/records/all/example.com", http.StatusOK, "error1.json") + client := mockBuilder(). + Route("GET /zones/records/all/example.com", servermock.ResponseFromFixture("error1.json")). + Build(t) _, err := client.ListZones(t.Context(), "example.com") require.EqualError(t, err, "code 420: Enhance Your Calm. Rate limit exceeded (too many requests) OR you did NOT provide any credentials with your request!") } func TestClient_AddRecord(t *testing.T) { - client := setupTest(t, http.MethodPut, "/zones/records/add/example.com/TXT", http.StatusCreated, "add-record.json") + client := mockBuilder(). + Route("PUT /zones/records/add/example.com/TXT", + servermock.ResponseFromFixture("add-record.json").WithStatusCode(http.StatusCreated), + servermock.CheckRequestJSONBody(`{"domain":"example.com","host":"test631","ttl":"300","prio":"0","type":"TXT","rdata":"txt"}`)). + Build(t) record := ZoneRecord{ Domain: "example.com", @@ -112,7 +79,10 @@ func TestClient_AddRecord(t *testing.T) { } func TestClient_AddRecord_error(t *testing.T) { - client := setupTest(t, http.MethodPut, "/zones/records/add/example.com/TXT", http.StatusCreated, "error1.json") + client := mockBuilder(). + Route("PUT /zones/records/add/example.com/TXT", + servermock.ResponseFromFixture("error1.json").WithStatusCode(http.StatusCreated)). + Build(t) record := ZoneRecord{ Domain: "example.com", @@ -128,7 +98,9 @@ func TestClient_AddRecord_error(t *testing.T) { } func TestClient_DeleteRecord(t *testing.T) { - client := setupTest(t, http.MethodDelete, "/zones/records/example.com/xxx", http.StatusOK, "") + client := mockBuilder(). + Route("DELETE /zones/records/example.com/xxx", nil). + Build(t) err := client.DeleteRecord(t.Context(), "example.com", "xxx") require.NoError(t, err) diff --git a/providers/dns/efficientip/internal/client_test.go b/providers/dns/efficientip/internal/client_test.go index 137f2628c..5d68b7d7f 100644 --- a/providers/dns/efficientip/internal/client_test.go +++ b/providers/dns/efficientip/internal/client_test.go @@ -1,79 +1,38 @@ package internal import ( - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, method, pattern string, status int, file string) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + srvURL, _ := url.Parse(server.URL) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) + client := NewClient(srvURL.Host, "user", "secret") + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) - mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - username, password, ok := req.BasicAuth() - if !ok { - http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return - } - - if username != "user" { - http.Error(rw, fmt.Sprintf("username: want %s got %s", username, "user"), http.StatusUnauthorized) - return - } - - if password != "secret" { - http.Error(rw, fmt.Sprintf("password: want %s got %s", password, "secret"), http.StatusUnauthorized) - return - } - - open, err := os.Open(filepath.Join("fixtures", file)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = open.Close() }() - - rw.WriteHeader(status) - _, err = io.Copy(rw, open) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - srvURL, _ := url.Parse(server.URL) - - client := NewClient(srvURL.Host, "user", "secret") - client.HTTPClient = server.Client() - client.baseURL, _ = url.Parse(server.URL) - - return client + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithBasicAuth("user", "secret"), + ) } func TestListRecords(t *testing.T) { - client := setupTest(t, http.MethodGet, "/dns_rr_list", http.StatusOK, "dns_rr_list.json") + client := mockBuilder(). + Route("GET /dns_rr_list", servermock.ResponseFromFixture("dns_rr_list.json")). + Build(t) - ctx := t.Context() - - records, err := client.ListRecords(ctx) + records, err := client.ListRecords(t.Context()) require.NoError(t, err) expected := []ResourceRecord{ @@ -336,11 +295,13 @@ func TestListRecords(t *testing.T) { } func TestGetRecord(t *testing.T) { - client := setupTest(t, http.MethodGet, "/dns_rr_info", http.StatusOK, "dns_rr_info.json") + client := mockBuilder(). + Route("GET /dns_rr_info", servermock.ResponseFromFixture("dns_rr_info.json"), + servermock.CheckQueryParameter().Strict(). + With("rr_id", "239")). + Build(t) - ctx := t.Context() - - record, err := client.GetRecord(ctx, "239") + record, err := client.GetRecord(t.Context(), "239") require.NoError(t, err) expected := &ResourceRecord{ @@ -383,9 +344,11 @@ func TestGetRecord(t *testing.T) { } func TestAddRecord(t *testing.T) { - client := setupTest(t, http.MethodPost, "/dns_rr_add", http.StatusCreated, "dns_rr_add.json") - - ctx := t.Context() + client := mockBuilder(). + Route("POST /dns_rr_add", + servermock.ResponseFromFixture("dns_rr_add.json").WithStatusCode(http.StatusCreated), + servermock.CheckRequestJSONBody(`{"dns_name":"dns.smart","dnsview_name":"external","rr_name":"test.example.com","rr_type":"TXT","value1":"test"}`)). + Build(t) r := ResourceRecord{ RRName: "test.example.com", @@ -395,7 +358,7 @@ func TestAddRecord(t *testing.T) { DNSViewName: "external", } - resp, err := client.AddRecord(ctx, r) + resp, err := client.AddRecord(t.Context(), r) require.NoError(t, err) expected := &BaseOutput{RetOID: "239"} @@ -404,11 +367,13 @@ func TestAddRecord(t *testing.T) { } func TestDeleteRecord(t *testing.T) { - client := setupTest(t, http.MethodDelete, "/dns_rr_delete", http.StatusOK, "dns_rr_delete.json") + client := mockBuilder(). + Route("DELETE /dns_rr_delete", servermock.ResponseFromFixture("dns_rr_delete.json"), + servermock.CheckQueryParameter().Strict(). + With("rr_id", "251")). + Build(t) - ctx := t.Context() - - resp, err := client.DeleteRecord(ctx, DeleteInputParameters{RRID: "251"}) + resp, err := client.DeleteRecord(t.Context(), DeleteInputParameters{RRID: "251"}) require.NoError(t, err) expected := &BaseOutput{RetOID: "251"} @@ -417,10 +382,11 @@ func TestDeleteRecord(t *testing.T) { } func TestDeleteRecord_error(t *testing.T) { - client := setupTest(t, http.MethodDelete, "/dns_rr_delete", http.StatusBadRequest, "dns_rr_delete-error.json") + client := mockBuilder(). + Route("DELETE /dns_rr_delete", + servermock.ResponseFromFixture("dns_rr_delete-error.json").WithStatusCode(http.StatusBadRequest)). + Build(t) - ctx := t.Context() - - _, err := client.DeleteRecord(ctx, DeleteInputParameters{RRID: "251"}) + _, err := client.DeleteRecord(t.Context(), DeleteInputParameters{RRID: "251"}) require.ErrorAs(t, err, &APIError{}) } diff --git a/providers/dns/epik/internal/client_test.go b/providers/dns/epik/internal/client_test.go index b23862207..b7c6f97df 100644 --- a/providers/dns/epik/internal/client_test.go +++ b/providers/dns/epik/internal/client_test.go @@ -1,37 +1,36 @@ package internal import ( - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("secret") + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := NewClient("secret") - client.HTTPClient = server.Client() - client.baseURL, _ = url.Parse(server.URL) - - return client, mux + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(), + ) } func TestClient_GetDNSRecords(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/domains/example.com/records", testHandler(http.MethodGet, http.StatusOK, "getDnsRecord.json")) + client := mockBuilder(). + Route("GET /domains/example.com/records", + servermock.ResponseFromFixture("getDnsRecord.json"), + servermock.CheckQueryParameter().Strict(). + With("SIGNATURE", "secret")). + Build(t) records, err := client.GetDNSRecords(t.Context(), "example.com") require.NoError(t, err) @@ -88,18 +87,25 @@ func TestClient_GetDNSRecords(t *testing.T) { } func TestClient_GetDNSRecords_error(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/domains/example.com/records", testHandler(http.MethodGet, http.StatusUnauthorized, "error.json")) + client := mockBuilder(). + Route("GET /domains/example.com/records", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized), + servermock.CheckQueryParameter().Strict(). + With("SIGNATURE", "secret")). + Build(t) _, err := client.GetDNSRecords(t.Context(), "example.com") require.Error(t, err) } func TestClient_CreateHostRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/domains/example.com/records", testHandler(http.MethodPost, http.StatusOK, "createHostRecord.json")) + client := mockBuilder(). + Route("POST /domains/example.com/records", + servermock.ResponseFromFixture("createHostRecord.json"), + servermock.CheckQueryParameter().Strict(). + With("SIGNATURE", "secret")). + Build(t) record := RecordRequest{ Host: "www2", @@ -121,9 +127,13 @@ func TestClient_CreateHostRecord(t *testing.T) { } func TestClient_CreateHostRecord_error(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/domains/example.com/records", testHandler(http.MethodPost, http.StatusUnauthorized, "error.json")) + client := mockBuilder(). + Route("POST /domains/example.com/records", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized), + servermock.CheckQueryParameter().Strict(). + With("SIGNATURE", "secret")). + Build(t) record := RecordRequest{ Host: "www2", @@ -138,9 +148,13 @@ func TestClient_CreateHostRecord_error(t *testing.T) { } func TestClient_RemoveHostRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/domains/example.com/records", testHandler(http.MethodDelete, http.StatusOK, "removeHostRecord.json")) + client := mockBuilder(). + Route("DELETE /domains/example.com/records", + servermock.ResponseFromFixture("removeHostRecord.json"), + servermock.CheckQueryParameter().Strict(). + With("ID", "abc123"). + With("SIGNATURE", "secret")). + Build(t) data, err := client.RemoveHostRecord(t.Context(), "example.com", "abc123") require.NoError(t, err) @@ -154,45 +168,12 @@ func TestClient_RemoveHostRecord(t *testing.T) { } func TestClient_RemoveHostRecord_error(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/domains/example.com/records", testHandler(http.MethodDelete, http.StatusUnauthorized, "error.json")) + client := mockBuilder(). + Route("DELETE /domains/example.com/records", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) _, err := client.RemoveHostRecord(t.Context(), "example.com", "abc123") require.Error(t, err) } - -func testHandler(method string, statusCode int, filename string) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf(`{"message":"unsupported method: %s"}`, req.Method), http.StatusMethodNotAllowed) - return - } - - auth := req.URL.Query().Get("SIGNATURE") - if auth != "secret" { - http.Error(rw, fmt.Sprintf("invalid API key: %s", auth), http.StatusUnauthorized) - return - } - - rw.WriteHeader(statusCode) - - if statusCode == http.StatusNoContent { - return - } - - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, fmt.Sprintf(`{"message":"%v"}`, err), http.StatusInternalServerError) - return - } - - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, fmt.Sprintf(`{"message":"%v"}`, err), http.StatusInternalServerError) - return - } - } -} diff --git a/providers/dns/f5xc/internal/client_test.go b/providers/dns/f5xc/internal/client_test.go index 7b53a3bce..0357abb16 100644 --- a/providers/dns/f5xc/internal/client_test.go +++ b/providers/dns/f5xc/internal/client_test.go @@ -1,58 +1,39 @@ package internal import ( - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, pattern string, status int, filename string) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient("secret", "shortname") + if err != nil { + return nil, err + } - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) - mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) { - if filename == "" { - rw.WriteHeader(status) - return - } - - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = file.Close() }() - - rw.WriteHeader(status) - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - client, err := NewClient("secret", "shortname") - require.NoError(t, err) - - client.HTTPClient = server.Client() - client.baseURL, _ = url.Parse(server.URL) - - return client + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("APIToken secret")) } func TestClient_Create(t *testing.T) { - client := setupTest(t, "POST /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA", http.StatusOK, "create.json") + client := mockBuilder(). + Route("POST /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA", + servermock.ResponseFromFixture("create.json"), + servermock.CheckRequestJSONBody(`{"dns_zone_name":"example.com","group_name":"groupA","rrset":{"description":"lego","ttl":60,"txt_record":{"name":"wwww","values":["txt"]}}}`)). + Build(t) rrSet := RRSet{ Description: "lego", @@ -82,7 +63,10 @@ func TestClient_Create(t *testing.T) { } func TestClient_Create_error(t *testing.T) { - client := setupTest(t, "POST /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA", http.StatusBadRequest, "") + client := mockBuilder(). + Route("POST /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA", + servermock.Noop().WithStatusCode(http.StatusBadRequest)). + Build(t) rrSet := RRSet{ Description: "lego", @@ -98,7 +82,10 @@ func TestClient_Create_error(t *testing.T) { } func TestClient_Get(t *testing.T) { - client := setupTest(t, "GET /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA/www/TXT", http.StatusOK, "get.json") + client := mockBuilder(). + Route("GET /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA/www/TXT", + servermock.ResponseFromFixture("get.json")). + Build(t) result, err := client.GetRRSet(t.Context(), "example.com", "groupA", "www", "TXT") require.NoError(t, err) @@ -122,7 +109,10 @@ func TestClient_Get(t *testing.T) { } func TestClient_Get_not_found(t *testing.T) { - client := setupTest(t, "GET /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA/www/TXT", http.StatusNotFound, "error_404.json") + client := mockBuilder(). + Route("GET /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA/www/TXT", + servermock.ResponseFromFixture("error_404.json").WithStatusCode(http.StatusNotFound)). + Build(t) result, err := client.GetRRSet(t.Context(), "example.com", "groupA", "www", "TXT") require.NoError(t, err) @@ -131,14 +121,20 @@ func TestClient_Get_not_found(t *testing.T) { } func TestClient_Get_error(t *testing.T) { - client := setupTest(t, "GET /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA/www/TXT", http.StatusBadRequest, "") + client := mockBuilder(). + Route("GET /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA/www/TXT", + servermock.Noop().WithStatusCode(http.StatusBadRequest)). + Build(t) _, err := client.GetRRSet(t.Context(), "example.com", "groupA", "www", "TXT") require.Error(t, err) } func TestClient_Delete(t *testing.T) { - client := setupTest(t, "DELETE /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA/www/TXT", http.StatusOK, "get.json") + client := mockBuilder(). + Route("DELETE /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA/www/TXT", + servermock.ResponseFromFixture("get.json")). + Build(t) result, err := client.DeleteRRSet(t.Context(), "example.com", "groupA", "www", "TXT") require.NoError(t, err) @@ -162,14 +158,21 @@ func TestClient_Delete(t *testing.T) { } func TestClient_Delete_error(t *testing.T) { - client := setupTest(t, "DELETE /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA/www/TXT", http.StatusBadRequest, "") + client := mockBuilder(). + Route("DELETE /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA/www/TXT", + servermock.Noop().WithStatusCode(http.StatusBadRequest)). + Build(t) _, err := client.DeleteRRSet(t.Context(), "example.com", "groupA", "www", "TXT") require.Error(t, err) } func TestClient_Replace(t *testing.T) { - client := setupTest(t, "PUT /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA/www/TXT", http.StatusOK, "get.json") + client := mockBuilder(). + Route("PUT /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA/www/TXT", + servermock.ResponseFromFixture("get.json"), + servermock.CheckRequestJSONBody(`{"dns_zone_name":"example.com","group_name":"groupA","type":"TXT","rrset":{"description":"lego","ttl":60,"txt_record":{"name":"wwww","values":["txt"]}}}`)). + Build(t) rrSet := RRSet{ Description: "lego", @@ -202,7 +205,10 @@ func TestClient_Replace(t *testing.T) { } func TestClient_Replace_error(t *testing.T) { - client := setupTest(t, "PUT /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA/www/TXT", http.StatusBadRequest, "") + client := mockBuilder(). + Route("PUT /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA/www/TXT", + servermock.Noop().WithStatusCode(http.StatusBadRequest)). + Build(t) rrSet := RRSet{ Description: "lego", diff --git a/providers/dns/gandi/gandi_test.go b/providers/dns/gandi/gandi_test.go index 36bc4ccd2..4c37fb00e 100644 --- a/providers/dns/gandi/gandi_test.go +++ b/providers/dns/gandi/gandi_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) @@ -119,38 +120,41 @@ func TestDNSProvider(t *testing.T) { cleanupDeleteZoneRequestMock: cleanupDeleteZoneResponseMock, } - fakeKeyAuth := "XXXX" - regexpDate := regexp.MustCompile(`\[ACME Challenge [^\]:]*:[^\]]*\]`) - // start fake RPC server - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, "text/xml", r.Header.Get("Content-Type"), "invalid content type") + provider := servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.BaseURL = server.URL + "/" + config.APIKey = "123412341234123412341234" - req, errS := io.ReadAll(r.Body) - require.NoError(t, errS) + return NewDNSProviderConfig(config) + }, + servermock.CheckHeader().WithContentType("text/xml"), + ). + Route("POST /", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + require.Equal(t, "text/xml", req.Header.Get("Content-Type"), "invalid content type") - req = regexpDate.ReplaceAllLiteral(req, []byte(`[ACME Challenge 01 Jan 16 00:00 +0000]`)) - resp, ok := serverResponses[string(req)] - require.Truef(t, ok, "Server response for request not found: %s", string(req)) + body, errS := io.ReadAll(req.Body) + require.NoError(t, errS) - _, errS = io.Copy(w, strings.NewReader(resp)) - require.NoError(t, errS) - })) - t.Cleanup(server.Close) + body = regexpDate.ReplaceAllLiteral(body, []byte(`[ACME Challenge 01 Jan 16 00:00 +0000]`)) + resp, ok := serverResponses[string(body)] + require.Truef(t, ok, "Server response for request not found: %s", string(body)) + + _, errS = io.Copy(rw, strings.NewReader(resp)) + require.NoError(t, errS) + })). + Route("/", servermock.DumpRequest()). + Build(t) + + fakeKeyAuth := "XXXX" // define function to override findZoneByFqdn with fakeFindZoneByFqdn := func(fqdn string) (string, error) { return "example.com.", nil } - config := NewDefaultConfig() - config.BaseURL = server.URL + "/" - config.APIKey = "123412341234123412341234" - - provider, err := NewDNSProviderConfig(config) - require.NoError(t, err) - // override findZoneByFqdn function savedFindZoneByFqdn := provider.findZoneByFqdn t.Cleanup(func() { @@ -159,7 +163,7 @@ func TestDNSProvider(t *testing.T) { provider.findZoneByFqdn = fakeFindZoneByFqdn // run Present - err = provider.Present("abc.def.example.com", "", fakeKeyAuth) + err := provider.Present("abc.def.example.com", "", fakeKeyAuth) require.NoError(t, err) // run CleanUp diff --git a/providers/dns/gandi/internal/client_test.go b/providers/dns/gandi/internal/client_test.go new file mode 100644 index 000000000..573f812fa --- /dev/null +++ b/providers/dns/gandi/internal/client_test.go @@ -0,0 +1,99 @@ +package internal + +import ( + "net/http/httptest" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("secret") + client.BaseURL = server.URL + client.HTTPClient = server.Client() + + return client, nil + }, + servermock.CheckHeader().WithContentType("text/xml"), + ) +} + +func TestClient_GetZoneID(t *testing.T) { + client := mockBuilder(). + Route("POST /", servermock.ResponseFromFixture("get_zone_id.xml"), + servermock.CheckRequestBodyFromFile("get_zone_id-request.xml").IgnoreWhitespace()). + Build(t) + + zoneID, err := client.GetZoneID(t.Context(), "example.com") + require.NoError(t, err) + + assert.Equal(t, 1, zoneID) +} + +func TestClient_CloneZone(t *testing.T) { + client := mockBuilder(). + Route("POST /", servermock.ResponseFromFixture("clone_zone.xml"), + servermock.CheckRequestBodyFromFile("clone_zone-request.xml").IgnoreWhitespace()). + Build(t) + + zoneID, err := client.CloneZone(t.Context(), 6, "foo") + require.NoError(t, err) + + assert.Equal(t, 1, zoneID) +} + +func TestClient_NewZoneVersion(t *testing.T) { + client := mockBuilder(). + Route("POST /", servermock.ResponseFromFixture("new_zone_version.xml"), + servermock.CheckRequestBodyFromFile("new_zone_version-request.xml").IgnoreWhitespace()). + Build(t) + + zoneID, err := client.NewZoneVersion(t.Context(), 6) + require.NoError(t, err) + + assert.Equal(t, 1, zoneID) +} + +func TestClient_AddTXTRecord(t *testing.T) { + client := mockBuilder(). + Route("POST /", servermock.ResponseFromFixture("empty.xml"), + servermock.CheckRequestBodyFromFile("add_txt_record-request.xml").IgnoreWhitespace()). + Build(t) + + err := client.AddTXTRecord(t.Context(), 1, 123, "foo", "content", 120) + require.NoError(t, err) +} + +func TestClient_SetZoneVersion(t *testing.T) { + client := mockBuilder(). + Route("POST /", servermock.ResponseFromFixture("set_zone_version.xml"), + servermock.CheckRequestBodyFromFile("set_zone_version-request.xml").IgnoreWhitespace()). + Build(t) + + err := client.SetZoneVersion(t.Context(), 1, 123) + require.NoError(t, err) +} + +func TestClient_SetZone(t *testing.T) { + client := mockBuilder(). + Route("POST /", servermock.ResponseFromFixture("set_zone.xml"), + servermock.CheckRequestBodyFromFile("set_zone-request.xml").IgnoreWhitespace()). + Build(t) + + err := client.SetZone(t.Context(), "example.com", 1) + require.NoError(t, err) +} + +func TestClient_DeleteZone(t *testing.T) { + client := mockBuilder(). + Route("POST /", servermock.ResponseFromFixture("delete_zone.xml"), + servermock.CheckRequestBodyFromFile("delete_zone-request.xml").IgnoreWhitespace()). + Build(t) + + err := client.DeleteZone(t.Context(), 1) + require.NoError(t, err) +} diff --git a/providers/dns/gandi/internal/fixtures/add_txt_record-request.xml b/providers/dns/gandi/internal/fixtures/add_txt_record-request.xml new file mode 100644 index 000000000..001ee7a33 --- /dev/null +++ b/providers/dns/gandi/internal/fixtures/add_txt_record-request.xml @@ -0,0 +1,49 @@ + + + domain.zone.record.add + + + secret + + + + + 1 + + + + + 123 + + + + + + + type + + TXT + + + + name + + foo + + + + value + + content + + + + ttl + + 120 + + + + + + diff --git a/providers/dns/gandi/internal/fixtures/clone_zone-request.xml b/providers/dns/gandi/internal/fixtures/clone_zone-request.xml new file mode 100644 index 000000000..40ee87c7e --- /dev/null +++ b/providers/dns/gandi/internal/fixtures/clone_zone-request.xml @@ -0,0 +1,31 @@ + + + domain.zone.clone + + + secret + + + + + 6 + + + + + 0 + + + + + + + name + + foo + + + + + + diff --git a/providers/dns/gandi/internal/fixtures/clone_zone.xml b/providers/dns/gandi/internal/fixtures/clone_zone.xml new file mode 100644 index 000000000..2af93526e --- /dev/null +++ b/providers/dns/gandi/internal/fixtures/clone_zone.xml @@ -0,0 +1,22 @@ + + + + + + + id + + 1 + + + + foo + + 2 + + + + + + + diff --git a/providers/dns/gandi/internal/fixtures/delete_zone-request.xml b/providers/dns/gandi/internal/fixtures/delete_zone-request.xml new file mode 100644 index 000000000..0ba9cb766 --- /dev/null +++ b/providers/dns/gandi/internal/fixtures/delete_zone-request.xml @@ -0,0 +1,14 @@ + + + domain.zone.delete + + + secret + + + + + 1 + + + diff --git a/providers/dns/gandi/internal/fixtures/delete_zone.xml b/providers/dns/gandi/internal/fixtures/delete_zone.xml new file mode 100644 index 000000000..28ba00dc5 --- /dev/null +++ b/providers/dns/gandi/internal/fixtures/delete_zone.xml @@ -0,0 +1,9 @@ + + + + + true + + + + diff --git a/providers/dns/gandi/internal/fixtures/empty.xml b/providers/dns/gandi/internal/fixtures/empty.xml new file mode 100644 index 000000000..7843fd723 --- /dev/null +++ b/providers/dns/gandi/internal/fixtures/empty.xml @@ -0,0 +1,2 @@ + + diff --git a/providers/dns/gandi/internal/fixtures/get_zone_id-request.xml b/providers/dns/gandi/internal/fixtures/get_zone_id-request.xml new file mode 100644 index 000000000..173a725d8 --- /dev/null +++ b/providers/dns/gandi/internal/fixtures/get_zone_id-request.xml @@ -0,0 +1,14 @@ + + + domain.info + + + secret + + + + + example.com + + + diff --git a/providers/dns/gandi/internal/fixtures/get_zone_id.xml b/providers/dns/gandi/internal/fixtures/get_zone_id.xml new file mode 100644 index 000000000..2a11e0dff --- /dev/null +++ b/providers/dns/gandi/internal/fixtures/get_zone_id.xml @@ -0,0 +1,22 @@ + + + + + + + zone_id + + 1 + + + + foo + + 2 + + + + + + + diff --git a/providers/dns/gandi/internal/fixtures/new_zone_version-request.xml b/providers/dns/gandi/internal/fixtures/new_zone_version-request.xml new file mode 100644 index 000000000..2fbac82de --- /dev/null +++ b/providers/dns/gandi/internal/fixtures/new_zone_version-request.xml @@ -0,0 +1,14 @@ + + + domain.zone.version.new + + + secret + + + + + 6 + + + diff --git a/providers/dns/gandi/internal/fixtures/new_zone_version.xml b/providers/dns/gandi/internal/fixtures/new_zone_version.xml new file mode 100644 index 000000000..feb84e486 --- /dev/null +++ b/providers/dns/gandi/internal/fixtures/new_zone_version.xml @@ -0,0 +1,9 @@ + + + + + 1 + + + + diff --git a/providers/dns/gandi/internal/fixtures/set_zone-request.xml b/providers/dns/gandi/internal/fixtures/set_zone-request.xml new file mode 100644 index 000000000..71ac843fd --- /dev/null +++ b/providers/dns/gandi/internal/fixtures/set_zone-request.xml @@ -0,0 +1,19 @@ + + + domain.zone.set + + + secret + + + + + example.com + + + + + 1 + + + diff --git a/providers/dns/gandi/internal/fixtures/set_zone.xml b/providers/dns/gandi/internal/fixtures/set_zone.xml new file mode 100644 index 000000000..2a11e0dff --- /dev/null +++ b/providers/dns/gandi/internal/fixtures/set_zone.xml @@ -0,0 +1,22 @@ + + + + + + + zone_id + + 1 + + + + foo + + 2 + + + + + + + diff --git a/providers/dns/gandi/internal/fixtures/set_zone_version-request.xml b/providers/dns/gandi/internal/fixtures/set_zone_version-request.xml new file mode 100644 index 000000000..68a021446 --- /dev/null +++ b/providers/dns/gandi/internal/fixtures/set_zone_version-request.xml @@ -0,0 +1,19 @@ + + + domain.zone.version.set + + + secret + + + + + 1 + + + + + 123 + + + diff --git a/providers/dns/gandi/internal/fixtures/set_zone_version.xml b/providers/dns/gandi/internal/fixtures/set_zone_version.xml new file mode 100644 index 000000000..28ba00dc5 --- /dev/null +++ b/providers/dns/gandi/internal/fixtures/set_zone_version.xml @@ -0,0 +1,9 @@ + + + + + true + + + + diff --git a/providers/dns/gandiv5/gandiv5_test.go b/providers/dns/gandiv5/gandiv5_test.go index 57fed032e..451b1b683 100644 --- a/providers/dns/gandiv5/gandiv5_test.go +++ b/providers/dns/gandiv5/gandiv5_test.go @@ -1,15 +1,11 @@ package gandiv5 import ( - "fmt" - "io" - "net/http" "net/http/httptest" - "regexp" "testing" - "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) @@ -95,81 +91,32 @@ func TestNewDNSProviderConfig(t *testing.T) { // TestDNSProvider runs Present and CleanUp against a fake Gandi RPC // Server, whose responses are predetermined for particular requests. func TestDNSProvider(t *testing.T) { - // serverResponses is the JSON Request->Response map used by the - // fake JSON server. - serverResponses := map[string]map[string]string{ - http.MethodGet: { - ``: `{"rrset_ttl":300,"rrset_values":[],"rrset_name":"_acme-challenge.abc.def","rrset_type":"TXT"}`, + provider := servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.PersonalAccessToken = "123412341234123412341234" + config.BaseURL = server.URL + + return NewDNSProviderConfig(config) }, - http.MethodPut: { - `{"rrset_ttl":300,"rrset_values":["TOKEN"]}`: `{"message": "Zone Record Created"}`, - }, - http.MethodDelete: { - ``: ``, - }, - } + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("Bearer 123412341234123412341234"), + ). + Route("GET /domains/example.com/records/_acme-challenge.abc.def/TXT", + servermock.RawStringResponse(`{"rrset_ttl":300,"rrset_values":[],"rrset_name":"_acme-challenge.abc.def","rrset_type":"TXT"}`)). + Route("PUT /domains/example.com/records/_acme-challenge.abc.def/TXT", + servermock.RawStringResponse(`{"message": "Zone Record Created"}`), + servermock.CheckRequestJSONBody(`{"rrset_ttl":300,"rrset_values":["ezRpBPY8wH8djMLYjX2uCKPwiKDkFZ1SFMJ6ZXGlHrQ"]}`)). + Route("DELETE /domains/example.com/records/_acme-challenge.abc.def/TXT", nil). + Build(t) fakeKeyAuth := "XXXX" - regexpToken := regexp.MustCompile(`"rrset_values":\[".+"\]`) - - // start fake RPC server - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc("/domains/example.com/records/_acme-challenge.abc.def/TXT", func(rw http.ResponseWriter, req *http.Request) { - log.Infof("request: %s %s", req.Method, req.URL) - - if req.Header.Get("Authorization") != "Bearer 123412341234123412341234" { - http.Error(rw, `{"message": "missing or malformed Authorization"}`, http.StatusUnauthorized) - return - } - - if req.Method == http.MethodPost && req.Header.Get("Content-Type") != "application/json" { - http.Error(rw, `{"message": "invalid content type"}`, http.StatusBadRequest) - return - } - - body, errS := io.ReadAll(req.Body) - if errS != nil { - http.Error(rw, fmt.Sprintf(`{"message": "read body error: %v"}`, errS), http.StatusInternalServerError) - return - } - - body = regexpToken.ReplaceAllLiteral(body, []byte(`"rrset_values":["TOKEN"]`)) - - responses, ok := serverResponses[req.Method] - if !ok { - http.Error(rw, fmt.Sprintf(`{"message": "Server response for request not found: %#q"}`, string(body)), http.StatusInternalServerError) - return - } - - resp := responses[string(body)] - - _, errS = rw.Write([]byte(resp)) - if errS != nil { - http.Error(rw, fmt.Sprintf(`{"message": "failed to write response: %v"}`, errS), http.StatusInternalServerError) - return - } - }) - mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) { - log.Infof("request: %s %s", req.Method, req.URL) - http.Error(rw, fmt.Sprintf(`{"message": "URL doesn't match: %s"}`, req.URL), http.StatusNotFound) - }) - // define function to override findZoneByFqdn with fakeFindZoneByFqdn := func(fqdn string) (string, error) { return "example.com.", nil } - config := NewDefaultConfig() - config.PersonalAccessToken = "123412341234123412341234" - config.BaseURL = server.URL - - provider, err := NewDNSProviderConfig(config) - require.NoError(t, err) - // override findZoneByFqdn function savedFindZoneByFqdn := provider.findZoneByFqdn defer func() { @@ -178,7 +125,7 @@ func TestDNSProvider(t *testing.T) { provider.findZoneByFqdn = fakeFindZoneByFqdn // run Present - err = provider.Present("abc.def.example.com", "", fakeKeyAuth) + err := provider.Present("abc.def.example.com", "", fakeKeyAuth) require.NoError(t, err) // run CleanUp diff --git a/providers/dns/gandiv5/internal/client_test.go b/providers/dns/gandiv5/internal/client_test.go new file mode 100644 index 000000000..2465566f9 --- /dev/null +++ b/providers/dns/gandiv5/internal/client_test.go @@ -0,0 +1,48 @@ +package internal + +import ( + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("secret", "xxx") + client.BaseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() + + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + With("X-Api-Key", "secret"). + WithAuthorization("Bearer xxx"), + ) +} + +func TestClient_AddTXTRecord(t *testing.T) { + client := mockBuilder(). + Route("GET /domains/example.com/records/foo/TXT", + servermock.ResponseFromFixture("add_txt_record_get.json")). + Route("PUT /domains/example.com/records/foo/TXT", + servermock.ResponseFromFixture("api_response.json"), + servermock.CheckRequestJSONBody(`{"rrset_ttl":120,"rrset_values":["content","value1"]}`)). + Build(t) + + err := client.AddTXTRecord(t.Context(), "example.com", "foo", "content", 120) + require.NoError(t, err) +} + +func TestClient_DeleteTXTRecord(t *testing.T) { + client := mockBuilder(). + Route("DELETE /domains/example.com/records/foo/TXT", + servermock.ResponseFromFixture("api_response.json")). + Build(t) + + err := client.DeleteTXTRecord(t.Context(), "example.com", "foo") + require.NoError(t, err) +} diff --git a/providers/dns/gandiv5/internal/fixtures/add_txt_record_get.json b/providers/dns/gandiv5/internal/fixtures/add_txt_record_get.json new file mode 100644 index 000000000..fead6ab0a --- /dev/null +++ b/providers/dns/gandiv5/internal/fixtures/add_txt_record_get.json @@ -0,0 +1,8 @@ +{ + "rrset_ttl": 120, + "rrset_values": [ + "value1" + ], + "rrset_name": "foo", + "rrset_type": "TXT" +} diff --git a/providers/dns/gandiv5/internal/fixtures/api_response.json b/providers/dns/gandiv5/internal/fixtures/api_response.json new file mode 100644 index 000000000..47f4352ff --- /dev/null +++ b/providers/dns/gandiv5/internal/fixtures/api_response.json @@ -0,0 +1,4 @@ +{ + "message": "test", + "uuid": "123456789" +} diff --git a/providers/dns/gcloud/googlecloud_test.go b/providers/dns/gcloud/googlecloud_test.go index 15c61556c..7fda2f8f6 100644 --- a/providers/dns/gcloud/googlecloud_test.go +++ b/providers/dns/gcloud/googlecloud_test.go @@ -11,6 +11,7 @@ import ( "time" "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" "golang.org/x/oauth2/google" "google.golang.org/api/dns/v1" @@ -144,245 +145,160 @@ func TestNewDNSProviderConfig(t *testing.T) { } func TestPresentNoExistingRR(t *testing.T) { - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) + provider := mockBuilder(). + // getHostedZone + Route("GET /dns/v1/projects/manhattan/managedZones", + servermock.JSONEncode(&dns.ManagedZonesListResponse{ + ManagedZones: []*dns.ManagedZone{ + {Name: "test", Visibility: "public"}, + }, + }), + servermock.CheckQueryParameter().Strict(). + With("dnsName", "lego.wtf."). + With("prettyPrint", "false"). + With("alt", "json")). + // findTxtRecords + Route("GET /dns/v1/projects/manhattan/managedZones/test/rrsets", + servermock.JSONEncode(&dns.ResourceRecordSetsListResponse{ + Rrsets: []*dns.ResourceRecordSet{}, + }), + servermock.CheckQueryParameter().Strict(). + With("name", "_acme-challenge.lego.wtf."). + With("type", "TXT"). + With("prettyPrint", "false"). + With("alt", "json")). + // applyChanges [Create] + Route("POST /dns/v1/projects/manhattan/managedZones/test/changes", + http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + var chgReq dns.Change + if err := json.NewDecoder(req.Body).Decode(&chgReq); err != nil { + http.Error(rw, err.Error(), http.StatusBadRequest) + return + } - // getHostedZone: /manhattan/managedZones?alt=json&dnsName=lego.wtf. - mux.HandleFunc("/dns/v1/projects/manhattan/managedZones", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } + chgResp := chgReq + chgResp.Status = changeStatusDone - mzlrs := &dns.ManagedZonesListResponse{ - ManagedZones: []*dns.ManagedZone{ - {Name: "test", Visibility: "public"}, - }, - } - - err := json.NewEncoder(w).Encode(mzlrs) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) - - // findTxtRecords: /manhattan/managedZones/test/rrsets?alt=json&name=_acme-challenge.lego.wtf.&type=TXT - mux.HandleFunc("/dns/v1/projects/manhattan/managedZones/test/rrsets", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } - - rrslr := &dns.ResourceRecordSetsListResponse{ - Rrsets: []*dns.ResourceRecordSet{}, - } - - err := json.NewEncoder(w).Encode(rrslr) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) - - // applyChanges [Create]: /manhattan/managedZones/test/changes?alt=json - mux.HandleFunc("/dns/v1/projects/manhattan/managedZones/test/changes", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } - - var chgReq dns.Change - if err := json.NewDecoder(r.Body).Decode(&chgReq); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - chgResp := chgReq - chgResp.Status = changeStatusDone - - if err := json.NewEncoder(w).Encode(chgResp); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) - - config := NewDefaultConfig() - config.HTTPClient = &http.Client{Timeout: 10 * time.Second} - config.Project = "manhattan" - - p, err := NewDNSProviderConfig(config) - require.NoError(t, err) - - p.client.BasePath = server.URL + if err := json.NewEncoder(rw).Encode(chgResp); err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + }), + servermock.CheckQueryParameter().Strict(). + With("prettyPrint", "false"). + With("alt", "json")). + Build(t) domain := "lego.wtf" - err = p.Present(domain, "", "") + err := provider.Present(domain, "", "") require.NoError(t, err) } func TestPresentWithExistingRR(t *testing.T) { - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - // getHostedZone: /manhattan/managedZones?alt=json&dnsName=lego.wtf. - mux.HandleFunc("/dns/v1/projects/manhattan/managedZones", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } - - mzlrs := &dns.ManagedZonesListResponse{ - ManagedZones: []*dns.ManagedZone{ - {Name: "test", Visibility: "public"}, - }, - } - - err := json.NewEncoder(w).Encode(mzlrs) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) - - // findTxtRecords: /manhattan/managedZones/test/rrsets?alt=json&name=_acme-challenge.lego.wtf.&type=TXT - mux.HandleFunc("/dns/v1/projects/manhattan/managedZones/test/rrsets", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } - - rrslr := &dns.ResourceRecordSetsListResponse{ - Rrsets: []*dns.ResourceRecordSet{{ - Name: "_acme-challenge.lego.wtf.", - Rrdatas: []string{`"X7DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU"`, `"huji"`}, - Ttl: 120, - Type: "TXT", - }}, - } - - err := json.NewEncoder(w).Encode(rrslr) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) - - // applyChanges [Create]: /manhattan/managedZones/test/changes?alt=json - mux.HandleFunc("/dns/v1/projects/manhattan/managedZones/test/changes", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } - - var chgReq dns.Change - if err := json.NewDecoder(r.Body).Decode(&chgReq); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - if len(chgReq.Additions) > 0 { - sort.Strings(chgReq.Additions[0].Rrdatas) - } - - var prevVal string - for _, addition := range chgReq.Additions { - for _, value := range addition.Rrdatas { - if prevVal == value { - http.Error(w, fmt.Sprintf("The resource %s already exists", value), http.StatusConflict) + provider := mockBuilder(). + // getHostedZone + Route("GET /dns/v1/projects/manhattan/managedZones", + servermock.JSONEncode(&dns.ManagedZonesListResponse{ + ManagedZones: []*dns.ManagedZone{ + {Name: "test", Visibility: "public"}, + }, + }), + servermock.CheckQueryParameter().Strict(). + With("dnsName", "lego.wtf."). + With("prettyPrint", "false"). + With("alt", "json")). + // findTxtRecords + Route("GET /dns/v1/projects/manhattan/managedZones/test/rrsets", + servermock.JSONEncode(&dns.ResourceRecordSetsListResponse{ + Rrsets: []*dns.ResourceRecordSet{{ + Name: "_acme-challenge.lego.wtf.", + Rrdatas: []string{`"X7DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU"`, `"huji"`}, + Ttl: 120, + Type: "TXT", + }}, + }), + servermock.CheckQueryParameter().Strict(). + With("name", "_acme-challenge.lego.wtf."). + With("type", "TXT"). + With("prettyPrint", "false"). + With("alt", "json")). + // applyChanges [Create] + Route("POST /dns/v1/projects/manhattan/managedZones/test/changes", + http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + var chgReq dns.Change + if err := json.NewDecoder(req.Body).Decode(&chgReq); err != nil { + http.Error(rw, err.Error(), http.StatusBadRequest) return } - prevVal = value - } - } - chgResp := chgReq - chgResp.Status = changeStatusDone + if len(chgReq.Additions) > 0 { + sort.Strings(chgReq.Additions[0].Rrdatas) + } - if err := json.NewEncoder(w).Encode(chgResp); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) + var prevVal string + for _, addition := range chgReq.Additions { + for _, value := range addition.Rrdatas { + if prevVal == value { + http.Error(rw, fmt.Sprintf("The resource %s already exists", value), http.StatusConflict) + return + } + prevVal = value + } + } - config := NewDefaultConfig() - config.HTTPClient = &http.Client{Timeout: 10 * time.Second} - config.Project = "manhattan" + chgResp := chgReq + chgResp.Status = changeStatusDone - p, err := NewDNSProviderConfig(config) - require.NoError(t, err) - - p.client.BasePath = server.URL + if err := json.NewEncoder(rw).Encode(chgResp); err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + }), + servermock.CheckQueryParameter().Strict(). + With("prettyPrint", "false"). + With("alt", "json")). + Build(t) domain := "lego.wtf" - err = p.Present(domain, "", "") + err := provider.Present(domain, "", "") require.NoError(t, err) } func TestPresentSkipExistingRR(t *testing.T) { - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - // getHostedZone: /manhattan/managedZones?alt=json&dnsName=lego.wtf. - mux.HandleFunc("/dns/v1/projects/manhattan/managedZones", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } - - mzlrs := &dns.ManagedZonesListResponse{ - ManagedZones: []*dns.ManagedZone{ - {Name: "test", Visibility: "public"}, - }, - } - - err := json.NewEncoder(w).Encode(mzlrs) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) - - // findTxtRecords: /manhattan/managedZones/test/rrsets?alt=json&name=_acme-challenge.lego.wtf.&type=TXT - mux.HandleFunc("/dns/v1/projects/manhattan/managedZones/test/rrsets", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } - - rrslr := &dns.ResourceRecordSetsListResponse{ - Rrsets: []*dns.ResourceRecordSet{{ - Name: "_acme-challenge.lego.wtf.", - Rrdatas: []string{`"47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU"`, `"X7DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU"`, `"huji"`}, - Ttl: 120, - Type: "TXT", - }}, - } - - err := json.NewEncoder(w).Encode(rrslr) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) - - config := NewDefaultConfig() - config.HTTPClient = &http.Client{Timeout: 10 * time.Second} - config.Project = "manhattan" - - p, err := NewDNSProviderConfig(config) - require.NoError(t, err) - - p.client.BasePath = server.URL + provider := mockBuilder(). + // getHostedZone + Route("GET /dns/v1/projects/manhattan/managedZones", + servermock.JSONEncode(&dns.ManagedZonesListResponse{ + ManagedZones: []*dns.ManagedZone{ + {Name: "test", Visibility: "public"}, + }, + }), + servermock.CheckQueryParameter().Strict(). + With("dnsName", "lego.wtf."). + With("prettyPrint", "false"). + With("alt", "json")). + // findTxtRecords + Route("GET /dns/v1/projects/manhattan/managedZones/test/rrsets", + servermock.JSONEncode(&dns.ResourceRecordSetsListResponse{ + Rrsets: []*dns.ResourceRecordSet{{ + Name: "_acme-challenge.lego.wtf.", + Rrdatas: []string{`"47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU"`, `"X7DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU"`, `"huji"`}, + Ttl: 120, + Type: "TXT", + }}, + }), + servermock.CheckQueryParameter().Strict(). + With("name", "_acme-challenge.lego.wtf."). + With("type", "TXT"). + With("prettyPrint", "false"). + With("alt", "json")). + Build(t) domain := "lego.wtf" - err = p.Present(domain, "", "") + err := provider.Present(domain, "", "") require.NoError(t, err) } @@ -432,3 +348,20 @@ func TestLiveCleanUp(t *testing.T) { err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } + +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder(func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.HTTPClient = &http.Client{Timeout: 10 * time.Second} + config.Project = "manhattan" + + p, err := NewDNSProviderConfig(config) + if err != nil { + return nil, err + } + + p.client.BasePath = server.URL + + return p, err + }) +} diff --git a/providers/dns/gcore/internal/client_test.go b/providers/dns/gcore/internal/client_test.go index 21bdb8e05..4a0f83311 100644 --- a/providers/dns/gcore/internal/client_test.go +++ b/providers/dns/gcore/internal/client_test.go @@ -1,47 +1,41 @@ package internal import ( - "encoding/json" - "fmt" "net/http" "net/http/httptest" "net/url" - "reflect" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const ( - testToken = "test" - testRecordContent = "acme" - testRecordContent2 = "foo" - testTTL = 10 + testToken = "test" + testRecordContent = "acme" + testTTL = 10 ) -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient(testToken) + client.baseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := NewClient(testToken) - client.baseURL, _ = url.Parse(server.URL) - - return client, mux + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders()) } func TestClient_GetZone(t *testing.T) { - client, mux := setupTest(t) - expected := Zone{Name: "example.com"} - mux.Handle("/v2/zones/example.com", validationHandler{ - method: http.MethodGet, - next: handleJSONResponse(expected), - }) + client := mockBuilder(). + Route("GET /v2/zones/example.com", + servermock.JSONEncode(expected)). + Build(t) zone, err := client.GetZone(t.Context(), "example.com") require.NoError(t, err) @@ -50,20 +44,16 @@ func TestClient_GetZone(t *testing.T) { } func TestClient_GetZone_error(t *testing.T) { - client, mux := setupTest(t) - - mux.Handle("/v2/zones/example.com", validationHandler{ - method: http.MethodGet, - next: handleAPIError(), - }) + client := mockBuilder(). + Route("GET /v2/zones/example.com", + servermock.JSONEncode(APIError{Message: "oops"}).WithStatusCode(http.StatusInternalServerError)). + Build(t) _, err := client.GetZone(t.Context(), "example.com") - require.Error(t, err) + require.EqualError(t, err, "get zone example.com: 500: oops") } func TestClient_GetRRSet(t *testing.T) { - client, mux := setupTest(t) - expected := RRSet{ TTL: testTTL, Records: []Records{ @@ -71,10 +61,10 @@ func TestClient_GetRRSet(t *testing.T) { }, } - mux.Handle("/v2/zones/example.com/foo.example.com/TXT", validationHandler{ - method: http.MethodGet, - next: handleJSONResponse(expected), - }) + client := mockBuilder(). + Route("GET /v2/zones/example.com/foo.example.com/TXT", + servermock.JSONEncode(expected)). + Build(t) rrSet, err := client.GetRRSet(t.Context(), "example.com", "foo.example.com") require.NoError(t, err) @@ -83,173 +73,93 @@ func TestClient_GetRRSet(t *testing.T) { } func TestClient_GetRRSet_error(t *testing.T) { - client, mux := setupTest(t) - - mux.Handle("/v2/zones/example.com/foo.example.com/TXT", validationHandler{ - method: http.MethodGet, - next: handleAPIError(), - }) + client := mockBuilder(). + Route("GET /v2/zones/example.com/foo.example.com/TXT", + servermock.JSONEncode(APIError{Message: "oops"}).WithStatusCode(http.StatusInternalServerError)). + Build(t) _, err := client.GetRRSet(t.Context(), "example.com", "foo.example.com") - require.Error(t, err) + require.EqualError(t, err, "get txt records example.com -> foo.example.com: 500: oops") } func TestClient_DeleteRRSet(t *testing.T) { - client, mux := setupTest(t) - - mux.Handle("/v2/zones/test.example.com/my.test.example.com/"+txtRecordType, - validationHandler{method: http.MethodDelete}) + client := mockBuilder(). + Route("DELETE /v2/zones/test.example.com/my.test.example.com/TXT", nil). + Build(t) err := client.DeleteRRSet(t.Context(), "test.example.com", "my.test.example.com.") require.NoError(t, err) } func TestClient_DeleteRRSet_error(t *testing.T) { - client, mux := setupTest(t) - - mux.Handle("/v2/zones/test.example.com/my.test.example.com/"+txtRecordType, validationHandler{ - method: http.MethodDelete, - next: handleAPIError(), - }) + client := mockBuilder(). + Route("DELETE /v2/zones/test.example.com/my.test.example.com/TXT", + servermock.JSONEncode(APIError{Message: "oops"}).WithStatusCode(http.StatusInternalServerError)). + Build(t) err := client.DeleteRRSet(t.Context(), "test.example.com", "my.test.example.com.") require.NoError(t, err) } -func TestClient_AddRRSet(t *testing.T) { - testCases := []struct { - desc string - zone string - recordName string - value string - handledDomain string - handlers map[string]http.Handler - wantErr bool - }{ - { - desc: "success add", - zone: "test.example.com", - recordName: "my.test.example.com", - value: testRecordContent, - handlers: map[string]http.Handler{ - // createRRSet - "/v2/zones/test.example.com/my.test.example.com/" + txtRecordType: validationHandler{ - method: http.MethodPost, - next: handleAddRRSet([]Records{{Content: []string{testRecordContent}}}), - }, - }, - }, - { - desc: "success update", - zone: "test.example.com", - recordName: "my.test.example.com", - value: testRecordContent, - handlers: map[string]http.Handler{ - "/v2/zones/test.example.com/my.test.example.com/" + txtRecordType: http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { - switch req.Method { - case http.MethodGet: // GetRRSet - data := RRSet{ - TTL: testTTL, - Records: []Records{{Content: []string{testRecordContent2}}}, - } - handleJSONResponse(data).ServeHTTP(rw, req) - case http.MethodPut: // updateRRSet - expected := []Records{ - {Content: []string{testRecordContent}}, - {Content: []string{testRecordContent2}}, - } - handleAddRRSet(expected).ServeHTTP(rw, req) - default: - http.Error(rw, "wrong method", http.StatusMethodNotAllowed) - } - }), - }, - }, - { - desc: "not in the zone", - zone: "test.example.com", - recordName: "notfound.example.com", - value: testRecordContent, - wantErr: true, - }, - } +func TestClient_AddRRSet_add(t *testing.T) { + client := mockBuilder(). + // GetRRSet + Route("GET /v2/zones/test.example.com/my.test.example.com/TXT", + servermock.JSONEncode(APIError{Message: "not found"}).WithStatusCode(http.StatusBadRequest)). + // createRRSet + Route("POST /v2/zones/test.example.com/my.test.example.com/TXT", + servermock.JSONEncode([]Records{{Content: []string{testRecordContent}}}), + servermock.CheckRequestJSONBody(`{"ttl":10,"resource_records":[{"content":["acme"]}]}`)). + Build(t) - for _, test := range testCases { - t.Run(test.desc, func(t *testing.T) { - cl, mux := setupTest(t) - - for pattern, handler := range test.handlers { - mux.Handle(pattern, handler) - } - - err := cl.AddRRSet(t.Context(), test.zone, test.recordName, test.value, testTTL) - if test.wantErr { - require.Error(t, err) - return - } - - require.NoError(t, err) - }) - } + err := client.AddRRSet(t.Context(), "test.example.com", "my.test.example.com", testRecordContent, testTTL) + require.NoError(t, err) } -type validationHandler struct { - method string - next http.Handler +func TestClient_AddRRSet_add_error(t *testing.T) { + client := mockBuilder(). + // GetRRSet + Route("GET /v2/zones/test.example.com/my.test.example.com/TXT", + servermock.JSONEncode(APIError{Message: "not found"}).WithStatusCode(http.StatusBadRequest)). + // createRRSet + Route("POST /v2/zones/test.example.com/my.test.example.com/TXT", + servermock.JSONEncode(APIError{Message: "oops"}).WithStatusCode(http.StatusBadRequest)). + Build(t) + + err := client.AddRRSet(t.Context(), "test.example.com", "my.test.example.com", testRecordContent, testTTL) + require.EqualError(t, err, "400: oops") } -func (v validationHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) { - if req.Header.Get(authorizationHeader) != fmt.Sprintf("%s %s", tokenTypeHeader, testToken) { - rw.WriteHeader(http.StatusForbidden) - _ = json.NewEncoder(rw).Encode(APIError{Message: "token up for parsing was not passed through the context"}) - return - } +func TestClient_AddRRSet_update(t *testing.T) { + client := mockBuilder(). + // GetRRSet + Route("GET /v2/zones/test.example.com/my.test.example.com/TXT", + servermock.JSONEncode(RRSet{ + TTL: testTTL, + Records: []Records{{Content: []string{"foo"}}}, + })). + // updateRRSet + Route("PUT /v2/zones/test.example.com/my.test.example.com/TXT", nil, + servermock.CheckRequestJSONBody(`{"ttl":10,"resource_records":[{"content":["acme"]},{"content":["foo"]}]}`)). + Build(t) - if req.Method != v.method { - http.Error(rw, "wrong method", http.StatusMethodNotAllowed) - return - } - - if v.next != nil { - v.next.ServeHTTP(rw, req) - } + err := client.AddRRSet(t.Context(), "test.example.com", "my.test.example.com", testRecordContent, testTTL) + require.NoError(t, err) } -func handleAPIError() http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - rw.WriteHeader(http.StatusInternalServerError) - _ = json.NewEncoder(rw).Encode(APIError{Message: "oops"}) - } -} - -func handleJSONResponse(data any) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - err := json.NewEncoder(rw).Encode(data) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - } -} - -func handleAddRRSet(expected []Records) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - body := RRSet{} - - err := json.NewDecoder(req.Body).Decode(&body) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - if body.TTL != testTTL { - http.Error(rw, "wrong ttl", http.StatusInternalServerError) - return - } - - if !reflect.DeepEqual(body.Records, expected) { - http.Error(rw, "wrong resource records", http.StatusInternalServerError) - return - } - } +func TestClient_AddRRSet_update_error(t *testing.T) { + client := mockBuilder(). + // GetRRSet + Route("GET /v2/zones/test.example.com/my.test.example.com/TXT", + servermock.JSONEncode(RRSet{ + TTL: testTTL, + Records: []Records{{Content: []string{"foo"}}}, + })). + // updateRRSet + Route("PUT /v2/zones/test.example.com/my.test.example.com/TXT", + servermock.JSONEncode(APIError{Message: "oops"}).WithStatusCode(http.StatusBadRequest)). + Build(t) + + err := client.AddRRSet(t.Context(), "test.example.com", "my.test.example.com", testRecordContent, testTTL) + require.EqualError(t, err, "400: oops") } diff --git a/providers/dns/glesys/internal/client_test.go b/providers/dns/glesys/internal/client_test.go index ab30f9516..cd71757ff 100644 --- a/providers/dns/glesys/internal/client_test.go +++ b/providers/dns/glesys/internal/client_test.go @@ -1,68 +1,35 @@ package internal import ( - "fmt" - "io" - "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, method, pattern string, status int, file string) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("user", "secret") + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusBadRequest) - return - } - - apiUser, apiKey, ok := req.BasicAuth() - if apiUser != "user" || apiKey != "secret" || !ok { - http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return - } - - if file == "" { - rw.WriteHeader(status) - return - } - - open, err := os.Open(filepath.Join("fixtures", file)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = open.Close() }() - - rw.WriteHeader(status) - _, err = io.Copy(rw, open) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - client := NewClient("user", "secret") - client.HTTPClient = server.Client() - client.baseURL, _ = url.Parse(server.URL) - - return client + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithBasicAuth("user", "secret"), + ) } func TestClient_AddTXTRecord(t *testing.T) { - client := setupTest(t, http.MethodPost, "/domain/addrecord", http.StatusOK, "add-record.json") + client := mockBuilder(). + Route("POST /domain/addrecord", + servermock.ResponseFromFixture("add-record.json"), + servermock.CheckRequestJSONBody(`{"domainname":"example.com","host":"foo","type":"TXT","data":"txt","ttl":120}`)). + Build(t) recordID, err := client.AddTXTRecord(t.Context(), "example.com", "foo", "txt", 120) require.NoError(t, err) @@ -71,7 +38,11 @@ func TestClient_AddTXTRecord(t *testing.T) { } func TestClient_DeleteTXTRecord(t *testing.T) { - client := setupTest(t, http.MethodPost, "/domain/deleterecord", http.StatusOK, "delete-record.json") + client := mockBuilder(). + Route("POST /domain/deleterecord", + servermock.ResponseFromFixture("delete-record.json"), + servermock.CheckRequestJSONBody(`{"recordid":123}`)). + Build(t) err := client.DeleteTXTRecord(t.Context(), 123) require.NoError(t, err) diff --git a/providers/dns/godaddy/internal/client_test.go b/providers/dns/godaddy/internal/client_test.go index 64bf2b388..741a55f3e 100644 --- a/providers/dns/godaddy/internal/client_test.go +++ b/providers/dns/godaddy/internal/client_test.go @@ -1,37 +1,33 @@ package internal import ( - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("key", "secret") + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := NewClient("key", "secret") - client.HTTPClient = server.Client() - client.baseURL, _ = url.Parse(server.URL) - - return client, mux + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("sso-key key:secret")) } func TestClient_GetRecords(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/v1/domains/example.com/records/TXT/", testHandler(http.MethodGet, http.StatusOK, "getrecords.json")) + client := mockBuilder(). + Route("GET /v1/domains/example.com/records/TXT/", servermock.ResponseFromFixture("getrecords.json")). + Build(t) records, err := client.GetRecords(t.Context(), "example.com", "TXT", "") require.NoError(t, err) @@ -49,9 +45,10 @@ func TestClient_GetRecords(t *testing.T) { } func TestClient_GetRecords_errors(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/v1/domains/example.com/records/TXT/", testHandler(http.MethodGet, http.StatusUnprocessableEntity, "errors.json")) + client := mockBuilder(). + Route("GET /v1/domains/example.com/records/TXT/", + servermock.ResponseFromFixture("errors.json").WithStatusCode(http.StatusUnprocessableEntity)). + Build(t) records, err := client.GetRecords(t.Context(), "example.com", "TXT", "") require.EqualError(t, err, "[status code: 422] INVALID_BODY: Request body doesn't fulfill schema, see details in `fields`") @@ -59,20 +56,10 @@ func TestClient_GetRecords_errors(t *testing.T) { } func TestClient_UpdateTxtRecords(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/v1/domains/example.com/records/TXT/lego", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPut { - http.Error(rw, fmt.Sprintf(`{"message":"unsupported method: %s"}`, req.Method), http.StatusMethodNotAllowed) - return - } - - auth := req.Header.Get(authorizationHeader) - if auth != "sso-key key:secret" { - http.Error(rw, fmt.Sprintf("invalid API key or secret: %s", auth), http.StatusUnauthorized) - return - } - }) + client := mockBuilder(). + Route("PUT /v1/domains/example.com/records/TXT/lego", nil, + servermock.CheckRequestJSONBodyFromFile("update_records-request.json")). + Build(t) records := []DNSRecord{ {Name: "_acme-challenge", Type: "TXT", Data: " ", TTL: 600}, @@ -88,10 +75,11 @@ func TestClient_UpdateTxtRecords(t *testing.T) { } func TestClient_UpdateTxtRecords_errors(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/v1/domains/example.com/records/TXT/lego", - testHandler(http.MethodPut, http.StatusUnprocessableEntity, "errors.json")) + client := mockBuilder(). + Route("PUT /v1/domains/example.com/records/TXT/lego", + servermock.ResponseFromFixture("errors.json").WithStatusCode(http.StatusUnprocessableEntity), + servermock.CheckRequestJSONBodyFromFile("update_records-request.json")). + Build(t) records := []DNSRecord{ {Name: "_acme-challenge", Type: "TXT", Data: " ", TTL: 600}, @@ -107,54 +95,21 @@ func TestClient_UpdateTxtRecords_errors(t *testing.T) { } func TestClient_DeleteTxtRecords(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/v1/domains/example.com/records/TXT/foo", testHandler(http.MethodDelete, http.StatusNoContent, "")) + client := mockBuilder(). + Route("DELETE /v1/domains/example.com/records/TXT/foo", + servermock.Noop().WithStatusCode(http.StatusNoContent)). + Build(t) err := client.DeleteTxtRecords(t.Context(), "example.com", "foo") require.NoError(t, err) } func TestClient_DeleteTxtRecords_errors(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/v1/domains/example.com/records/TXT/foo", testHandler(http.MethodDelete, http.StatusConflict, "error-extended.json")) + client := mockBuilder(). + Route("DELETE /v1/domains/example.com/records/TXT/foo", + servermock.ResponseFromFixture("error-extended.json").WithStatusCode(http.StatusConflict)). + Build(t) err := client.DeleteTxtRecords(t.Context(), "example.com", "foo") require.EqualError(t, err, "[status code: 409] ACCESS_DENIED: Authenticated user is not allowed access [test: content (path=/foo) (pathRelated=/bar)]") } - -func testHandler(method string, statusCode int, filename string) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf(`{"message":"unsupported method: %s"}`, req.Method), http.StatusMethodNotAllowed) - return - } - - auth := req.Header.Get(authorizationHeader) - if auth != "sso-key key:secret" { - http.Error(rw, fmt.Sprintf("invalid API key or secret: %s", auth), http.StatusUnauthorized) - return - } - - rw.WriteHeader(statusCode) - - if statusCode == http.StatusNoContent { - return - } - - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, fmt.Sprintf(`{"message":"%v"}`, err), http.StatusInternalServerError) - return - } - - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, fmt.Sprintf(`{"message":"%v"}`, err), http.StatusInternalServerError) - return - } - } -} diff --git a/providers/dns/godaddy/internal/fixtures/update_records-request.json b/providers/dns/godaddy/internal/fixtures/update_records-request.json new file mode 100644 index 000000000..969afb2dc --- /dev/null +++ b/providers/dns/godaddy/internal/fixtures/update_records-request.json @@ -0,0 +1,38 @@ +[ + { + "name": "_acme-challenge", + "type": "TXT", + "data": " ", + "ttl": 600 + }, + { + "name": "_acme-challenge.example", + "type": "TXT", + "data": "6rrai7-jm7l3PxL4WGmGoS6VMeefSHx24r-qCvUIOxU", + "ttl": 600 + }, + { + "name": "_acme-challenge.example", + "type": "TXT", + "data": "8Axt-PXQvjOVD2oi2YXqyyn8U5CDcC8P-BphlCxk3Ek", + "ttl": 600 + }, + { + "name": "_acme-challenge.lego", + "type": "TXT", + "data": " ", + "ttl": 600 + }, + { + "name": "_acme-challenge.lego", + "type": "TXT", + "data": "0Ad60wO_yxxJPFPb1deir6lQ37FPLeA02YCobo7Qm8A", + "ttl": 600 + }, + { + "name": "_acme-challenge.lego", + "type": "TXT", + "data": "acme", + "ttl": 600 + } +] diff --git a/providers/dns/hetzner/internal/client_test.go b/providers/dns/hetzner/internal/client_test.go index fe9f992fb..d301493a9 100644 --- a/providers/dns/hetzner/internal/client_test.go +++ b/providers/dns/hetzner/internal/client_test.go @@ -1,107 +1,60 @@ package internal import ( - "fmt" - "io" - "net/http" "net/http/httptest" "net/url" - "os" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, apiKey string) (*Client, *http.ServeMux) { - t.Helper() +func mockBuilder(apiKey string) *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient(apiKey) + client.baseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := NewClient(apiKey) - client.baseURL, _ = url.Parse(server.URL) - client.HTTPClient = server.Client() - - return client, mux + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + With(authHeader, apiKey)) } func TestClient_GetTxtRecord(t *testing.T) { const zoneID = "zoneA" - const apiKey = "myKeyA" - client, mux := setupTest(t, apiKey) - - mux.HandleFunc("/api/v1/records", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - auth := req.Header.Get(authHeader) - if auth != apiKey { - http.Error(rw, fmt.Sprintf("invalid API key: %s", auth), http.StatusUnauthorized) - return - } - - zID := req.URL.Query().Get("zone_id") - if zID != zoneID { - http.Error(rw, fmt.Sprintf("invalid zone ID: %s", zID), http.StatusBadRequest) - return - } - - file, err := os.Open("./fixtures/get_txt_record.json") - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder("myKeyA"). + Route("GET /api/v1/records", servermock.ResponseFromFixture("get_txt_record.json"), + servermock.CheckQueryParameter().Strict(). + With("zone_id", zoneID)). + Build(t) record, err := client.GetTxtRecord(t.Context(), "test1", "txttxttxt", zoneID) require.NoError(t, err) - fmt.Println(record) + expected := &DNSRecord{ + ID: "1b", + Name: "test1", + Type: "TXT", + Value: "txttxttxt", + Priority: 0, + TTL: 600, + ZoneID: "zoneA", + } + + assert.Equal(t, expected, record) } func TestClient_CreateRecord(t *testing.T) { const zoneID = "zoneA" - const apiKey = "myKeyB" - client, mux := setupTest(t, apiKey) - - mux.HandleFunc("/api/v1/records", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - auth := req.Header.Get(authHeader) - if auth != apiKey { - http.Error(rw, fmt.Sprintf("invalid API key: %s", auth), http.StatusUnauthorized) - return - } - - file, err := os.Open("./fixtures/create_txt_record.json") - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder("myKeyB"). + Route("POST /api/v1/records", servermock.ResponseFromFixture("create_txt_record.json"), + servermock.CheckRequestJSONBodyFromFile("create_txt_record-request.json")). + Build(t) record := DNSRecord{ Name: "test", @@ -116,57 +69,18 @@ func TestClient_CreateRecord(t *testing.T) { } func TestClient_DeleteRecord(t *testing.T) { - const apiKey = "myKeyC" - - client, mux := setupTest(t, apiKey) - - mux.HandleFunc("/api/v1/records/recordID", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodDelete { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - auth := req.Header.Get(authHeader) - if auth != apiKey { - http.Error(rw, fmt.Sprintf("invalid API key: %s", auth), http.StatusUnauthorized) - return - } - }) + client := mockBuilder("myKeyC"). + Route("DELETE /api/v1/records/recordID", nil). + Build(t) err := client.DeleteRecord(t.Context(), "recordID") require.NoError(t, err) } func TestClient_GetZoneID(t *testing.T) { - const apiKey = "myKeyD" - - client, mux := setupTest(t, apiKey) - - mux.HandleFunc("/api/v1/zones", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - auth := req.Header.Get(authHeader) - if auth != apiKey { - http.Error(rw, fmt.Sprintf("invalid API key: %s", auth), http.StatusUnauthorized) - return - } - - file, err := os.Open("./fixtures/get_zone_id.json") - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder("myKeyD"). + Route("GET /api/v1/zones", servermock.ResponseFromFixture("get_zone_id.json")). + Build(t) zoneID, err := client.GetZoneID(t.Context(), "example.com") require.NoError(t, err) diff --git a/providers/dns/hetzner/internal/fixtures/create_txt_record-request.json b/providers/dns/hetzner/internal/fixtures/create_txt_record-request.json new file mode 100644 index 000000000..894d81886 --- /dev/null +++ b/providers/dns/hetzner/internal/fixtures/create_txt_record-request.json @@ -0,0 +1,7 @@ +{ + "name": "test", + "type": "TXT", + "value": "txttxttxt", + "ttl": 600, + "zone_id": "zoneA" +} diff --git a/providers/dns/hosttech/internal/client_test.go b/providers/dns/hosttech/internal/client_test.go index 3acbaafc5..223a0d9cf 100644 --- a/providers/dns/hosttech/internal/client_test.go +++ b/providers/dns/hosttech/internal/client_test.go @@ -1,23 +1,38 @@ package internal import ( - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const testAPIKey = "secret" +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient(OAuthStaticAccessToken(server.Client(), testAPIKey)) + client.baseURL, _ = url.Parse(server.URL) + + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("Bearer secret")) +} + func TestClient_GetZones(t *testing.T) { - client := setupTest(t, "/user/v1/zones", testHandler(http.MethodGet, http.StatusOK, "zones.json")) + client := mockBuilder(). + Route("GET /user/v1/zones", + servermock.ResponseFromFixture("zones.json"), + servermock.CheckQueryParameter().Strict(). + With("limit", "100"). + With("query", "")). + Build(t) zones, err := client.GetZones(t.Context(), "", 100, 0) require.NoError(t, err) @@ -38,14 +53,21 @@ func TestClient_GetZones(t *testing.T) { } func TestClient_GetZones_error(t *testing.T) { - client := setupTest(t, "/user/v1/zones", testHandler(http.MethodGet, http.StatusUnauthorized, "error.json")) + client := mockBuilder(). + Route("GET /user/v1/zones", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) _, err := client.GetZones(t.Context(), "", 100, 0) require.Error(t, err) } func TestClient_GetZone(t *testing.T) { - client := setupTest(t, "/user/v1/zones/123", testHandler(http.MethodGet, http.StatusOK, "zone.json")) + client := mockBuilder(). + Route("GET /user/v1/zones/123", + servermock.ResponseFromFixture("zone.json")). + Build(t) zone, err := client.GetZone(t.Context(), "123") require.NoError(t, err) @@ -64,14 +86,23 @@ func TestClient_GetZone(t *testing.T) { } func TestClient_GetZone_error(t *testing.T) { - client := setupTest(t, "/user/v1/zones/123", testHandler(http.MethodGet, http.StatusUnauthorized, "error.json")) + client := mockBuilder(). + Route("GET /user/v1/zones/123", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) _, err := client.GetZone(t.Context(), "123") - require.Error(t, err) + require.EqualError(t, err, "401: Unauthenticated.") } func TestClient_GetRecords(t *testing.T) { - client := setupTest(t, "/user/v1/zones/123/records", testHandler(http.MethodGet, http.StatusOK, "records.json")) + client := mockBuilder(). + Route("GET /user/v1/zones/123/records", + servermock.ResponseFromFixture("records.json"), + servermock.CheckQueryParameter().Strict(). + With("type", "TXT")). + Build(t) records, err := client.GetRecords(t.Context(), "123", "TXT") require.NoError(t, err) @@ -151,14 +182,22 @@ func TestClient_GetRecords(t *testing.T) { } func TestClient_GetRecords_error(t *testing.T) { - client := setupTest(t, "/user/v1/zones/123/records", testHandler(http.MethodGet, http.StatusUnauthorized, "error.json")) + client := mockBuilder(). + Route("GET /user/v1/zones/123/records", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) _, err := client.GetRecords(t.Context(), "123", "TXT") - require.Error(t, err) + require.EqualError(t, err, "401: Unauthenticated.") } func TestClient_AddRecord(t *testing.T) { - client := setupTest(t, "/user/v1/zones/123/records", testHandler(http.MethodPost, http.StatusCreated, "record.json")) + client := mockBuilder(). + Route("POST /user/v1/zones/123/records", + servermock.ResponseFromFixture("record.json"). + WithStatusCode(http.StatusCreated)). + Build(t) record := Record{ Type: "TXT", @@ -184,7 +223,11 @@ func TestClient_AddRecord(t *testing.T) { } func TestClient_AddRecord_error(t *testing.T) { - client := setupTest(t, "/user/v1/zones/123/records", testHandler(http.MethodPost, http.StatusUnauthorized, "error-details.json")) + client := mockBuilder(). + Route("POST /user/v1/zones/123/records", + servermock.ResponseFromFixture("error-details.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) record := Record{ Type: "TXT", @@ -195,68 +238,27 @@ func TestClient_AddRecord_error(t *testing.T) { } _, err := client.AddRecord(t.Context(), "123", record) - require.Error(t, err) + require.EqualError(t, err, "401: The given data was invalid. type: [Darf nicht leer sein.]") } func TestClient_DeleteRecord(t *testing.T) { - client := setupTest(t, "/user/v1/zones/123/records/6", testHandler(http.MethodDelete, http.StatusUnauthorized, "error.json")) + client := mockBuilder(). + Route("DELETE /user/v1/zones/123/records/6", + servermock.Noop().WithStatusCode(http.StatusNoContent). + WithStatusCode(http.StatusCreated)). + Build(t) err := client.DeleteRecord(t.Context(), "123", "6") require.Error(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { - client := setupTest(t, "/user/v1/zones/123/records/6", testHandler(http.MethodDelete, http.StatusNoContent, "")) + client := mockBuilder(). + Route("DELETE /user/v1/zones/123/records/6", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) err := client.DeleteRecord(t.Context(), "123", "6") - require.NoError(t, err) -} - -func setupTest(t *testing.T, path string, handler http.Handler) *Client { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.Handle(path, handler) - - client := NewClient(OAuthStaticAccessToken(server.Client(), testAPIKey)) - client.baseURL, _ = url.Parse(server.URL) - - return client -} - -func testHandler(method string, statusCode int, filename string) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf(`{"message":"unsupported method: %s"}`, req.Method), http.StatusMethodNotAllowed) - return - } - - if req.Header.Get("Authorization") != "Bearer "+testAPIKey { - http.Error(rw, `{"message":"Unauthenticated"}`, http.StatusUnauthorized) - return - } - - rw.WriteHeader(statusCode) - - if statusCode == http.StatusNoContent { - return - } - - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, fmt.Sprintf(`{"message":"%v"}`, err), http.StatusInternalServerError) - return - } - - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, fmt.Sprintf(`{"message":"%v"}`, err), http.StatusInternalServerError) - return - } - } + require.EqualError(t, err, "401: Unauthenticated.") } diff --git a/providers/dns/httpreq/httpreq_test.go b/providers/dns/httpreq/httpreq_test.go index 8dc36ccc6..038b21b1a 100644 --- a/providers/dns/httpreq/httpreq_test.go +++ b/providers/dns/httpreq/httpreq_test.go @@ -1,15 +1,12 @@ package httpreq import ( - "encoding/json" - "fmt" - "net/http" "net/http/httptest" "net/url" - "path" "testing" "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) @@ -102,75 +99,60 @@ func TestNewDNSProvider_Present(t *testing.T) { testCases := []struct { desc string - mode string - username string - password string - pathPrefix string - handler http.HandlerFunc + builder *servermock.Builder[*DNSProvider] expectedError string }{ { - desc: "success", - handler: successHandler, + desc: "success", + builder: mockBuilder(""). + Route("/present", + servermock.RawStringResponse("lego"), + servermock.CheckRequestJSONBody(`{"fqdn":"_acme-challenge.domain.","value":"LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM"}`)), }, { - desc: "success with path prefix", - handler: successHandler, - pathPrefix: "/api/acme/", + desc: "success with path prefix", + builder: mockBuilderWithPathPrefix("", "/api/acme/"). + Route("/api/acme/present", + servermock.RawStringResponse("lego"), + servermock.CheckRequestJSONBody(`{"fqdn":"_acme-challenge.domain.","value":"LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM"}`)), }, { desc: "error", - handler: http.NotFound, + builder: mockBuilder(""), expectedError: "httpreq: unexpected status code: [status code: 404] body: 404 page not found", }, { - desc: "success raw mode", - mode: "RAW", - handler: successRawModeHandler, + desc: "success raw mode", + builder: mockBuilder("RAW"). + Route("/present", + servermock.RawStringResponse("lego"), + servermock.CheckRequestBody(`{"domain":"domain","token":"token","keyAuth":"key"}`)), }, { desc: "error raw mode", - mode: "RAW", - handler: http.NotFound, + builder: mockBuilder("RAW"), expectedError: "httpreq: unexpected status code: [status code: 404] body: 404 page not found", }, { - desc: "basic auth", - username: "bar", - password: "foo", - handler: func(rw http.ResponseWriter, req *http.Request) { - username, password, ok := req.BasicAuth() - if username != "bar" || password != "foo" || !ok { - rw.Header().Set("WWW-Authenticate", fmt.Sprintf(`Basic realm=%q`, "Please enter your username and password.")) - http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return - } - - fmt.Fprint(rw, "lego") - }, + desc: "basic auth fail", + builder: mockBuilderWithBasicAuth("nope", "nope"). + Route("/present", servermock.Noop()), + expectedError: `httpreq: unexpected status code: [status code: 400] body: invalid credentials: got [username: "nope", password: "nope"], want [username: "user", password: "secret"]`, + }, + { + desc: "basic auth success", + builder: mockBuilderWithBasicAuth("user", "secret"). + Route("/present", + servermock.RawStringResponse("lego"), + servermock.CheckRequestJSONBody(`{"fqdn":"_acme-challenge.domain.","value":"LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM"}`)), }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - t.Parallel() + p := test.builder.Build(t) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc(path.Join("/", test.pathPrefix, "present"), test.handler) - - config := NewDefaultConfig() - config.Endpoint = mustParse(server.URL + test.pathPrefix) - config.Mode = test.mode - config.Username = test.username - config.Password = test.password - - p, err := NewDNSProviderConfig(config) - require.NoError(t, err) - - err = p.Present("domain", "token", "key") + err := p.Present("domain", "token", "key") if test.expectedError == "" { require.NoError(t, err) } else { @@ -185,68 +167,53 @@ func TestNewDNSProvider_Cleanup(t *testing.T) { testCases := []struct { desc string - mode string - username string - password string - handler http.HandlerFunc + builder *servermock.Builder[*DNSProvider] expectedError string }{ { - desc: "success", - handler: successHandler, + desc: "success", + builder: mockBuilder(""). + Route("/cleanup", + servermock.RawStringResponse("lego"), + servermock.CheckRequestJSONBody(`{"fqdn":"_acme-challenge.domain.","value":"LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM"}`)), }, { desc: "error", - handler: http.NotFound, + builder: mockBuilder(""), expectedError: "httpreq: unexpected status code: [status code: 404] body: 404 page not found", }, { - desc: "success raw mode", - mode: "RAW", - handler: successRawModeHandler, + desc: "success raw mode", + builder: mockBuilder("RAW"). + Route("/cleanup", + servermock.RawStringResponse("lego"), + servermock.CheckRequestBody(`{"domain":"domain","token":"token","keyAuth":"key"}`)), }, { desc: "error raw mode", - mode: "RAW", - handler: http.NotFound, + builder: mockBuilder("RAW"), expectedError: "httpreq: unexpected status code: [status code: 404] body: 404 page not found", }, { - desc: "basic auth", - username: "bar", - password: "foo", - handler: func(rw http.ResponseWriter, req *http.Request) { - username, password, ok := req.BasicAuth() - if username != "bar" || password != "foo" || !ok { - rw.Header().Set("WWW-Authenticate", fmt.Sprintf(`Basic realm=%q`, "Please enter your username and password.")) - http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return - } - fmt.Fprint(rw, "lego") - }, + desc: "basic auth fail", + builder: mockBuilderWithBasicAuth("test", "example"). + Route("/cleanup", servermock.Noop()), + expectedError: `httpreq: unexpected status code: [status code: 400] body: invalid credentials: got [username: "test", password: "example"], want [username: "user", password: "secret"]`, + }, + { + desc: "basic auth success", + builder: mockBuilderWithBasicAuth("user", "secret"). + Route("/cleanup", + servermock.RawStringResponse("lego"), + servermock.CheckRequestJSONBody(`{"fqdn":"_acme-challenge.domain.","value":"LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM"}`)), }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - t.Parallel() + p := test.builder.Build(t) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc("/cleanup", test.handler) - - config := NewDefaultConfig() - config.Endpoint = mustParse(server.URL) - config.Mode = test.mode - config.Username = test.username - config.Password = test.password - - p, err := NewDNSProviderConfig(config) - require.NoError(t, err) - - err = p.CleanUp("domain", "token", "key") + err := p.CleanUp("domain", "token", "key") if test.expectedError == "" { require.NoError(t, err) } else { @@ -256,36 +223,39 @@ func TestNewDNSProvider_Cleanup(t *testing.T) { } } -func successHandler(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } +func mockBuilder(mode string) *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.Endpoint, _ = url.Parse(server.URL) + config.Mode = mode - msg := &message{} - err := json.NewDecoder(req.Body).Decode(msg) - if err != nil { - http.Error(rw, err.Error(), http.StatusBadRequest) - return - } - - fmt.Fprint(rw, "lego") + return NewDNSProviderConfig(config) + }) } -func successRawModeHandler(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } +func mockBuilderWithPathPrefix(mode, prefix string) *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.Endpoint, _ = url.Parse(server.URL + prefix) + config.Mode = mode - msg := &messageRaw{} - err := json.NewDecoder(req.Body).Decode(msg) - if err != nil { - http.Error(rw, err.Error(), http.StatusBadRequest) - return - } + return NewDNSProviderConfig(config) + }) +} - fmt.Fprint(rw, "lego") +func mockBuilderWithBasicAuth(username, password string) *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.Endpoint, _ = url.Parse(server.URL) + config.Username = username + config.Password = password + + return NewDNSProviderConfig(config) + }, + servermock.CheckHeader().WithBasicAuth("user", "secret")) } func mustParse(rawURL string) *url.URL { diff --git a/providers/dns/hurricane/internal/client_test.go b/providers/dns/hurricane/internal/client_test.go index 2e55c2057..d93f3e0ed 100644 --- a/providers/dns/hurricane/internal/client_test.go +++ b/providers/dns/hurricane/internal/client_test.go @@ -1,14 +1,21 @@ package internal import ( - "fmt" - "net/http" "net/http/httptest" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" ) +func setupClient(server *httptest.Server) (*Client, error) { + client := NewClient(map[string]string{"example.com": "secret"}) + client.baseURL = server.URL + client.HTTPClient = server.Client() + + return client, nil +} + func TestClient_UpdateTxtRecord(t *testing.T) { testCases := []struct { code string @@ -48,31 +55,14 @@ func TestClient_UpdateTxtRecord(t *testing.T) { t.Run(test.code, func(t *testing.T) { t.Parallel() - handler := http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - if err := req.ParseForm(); err != nil { - http.Error(rw, "failed to parse form data", http.StatusBadRequest) - return - } - - if req.PostForm.Encode() != "hostname=_acme-challenge.example.com&password=secret&txt=foo" { - http.Error(rw, "invalid form data", http.StatusBadRequest) - return - } - - _, _ = rw.Write([]byte(test.code)) - }) - - server := httptest.NewServer(handler) - t.Cleanup(server.Close) - - client := NewClient(map[string]string{"example.com": "secret"}) - client.baseURL = server.URL - client.HTTPClient = server.Client() + client := servermock.NewBuilder[*Client](setupClient, servermock.CheckHeader().WithContentTypeFromURLEncoded()). + Route("POST /", + servermock.RawStringResponse(test.code), + servermock.CheckForm().Strict(). + With("hostname", "_acme-challenge.example.com"). + With("password", "secret"). + With("txt", "foo")). + Build(t) err := client.UpdateTxtRecord(t.Context(), "_acme-challenge.example.com", "foo") test.expected(t, err) diff --git a/providers/dns/hyperone/internal/client_test.go b/providers/dns/hyperone/internal/client_test.go index 55c43700c..aa087c4f2 100644 --- a/providers/dns/hyperone/internal/client_test.go +++ b/providers/dns/hyperone/internal/client_test.go @@ -1,16 +1,10 @@ package internal import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" "net/http/httptest" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -21,8 +15,32 @@ func (s signerMock) GetJWT() (string, error) { return "", nil } +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + passport := &Passport{ + SubjectID: "/iam/project/proj123/sa/xxxxxxx", + } + + client, err := NewClient(server.URL, "loc123", passport) + if err != nil { + return nil, err + } + + client.HTTPClient = server.Client() + client.signer = signerMock{} + + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("Bearer")) +} + func TestClient_FindRecordset(t *testing.T) { - client := setupTest(t, http.MethodGet, "/dns/loc123/project/proj123/zone/zone321/recordset", respFromFile("recordset.json")) + client := mockBuilder(). + Route("GET /dns/loc123/project/proj123/zone/zone321/recordset", + servermock.ResponseFromFixture("recordset.json")). + Build(t) recordset, err := client.FindRecordset(t.Context(), "zone321", "SOA", "example.com.") require.NoError(t, err) @@ -45,8 +63,11 @@ func TestClient_CreateRecordset(t *testing.T) { Record: &Record{Content: "value"}, } - client := setupTest(t, http.MethodPost, "/dns/loc123/project/proj123/zone/zone123/recordset", - hasReqBody(expectedReqBody), respFromFile("createRecordset.json")) + client := mockBuilder(). + Route("POST /dns/loc123/project/proj123/zone/zone123/recordset", + servermock.ResponseFromFixture("createRecordset.json"), + servermock.CheckRequestJSONBodyFromStruct(expectedReqBody)). + Build(t) rs, err := client.CreateRecordset(t.Context(), "zone123", "TXT", "test.example.com.", "value", 3600) require.NoError(t, err) @@ -56,14 +77,19 @@ func TestClient_CreateRecordset(t *testing.T) { } func TestClient_DeleteRecordset(t *testing.T) { - client := setupTest(t, http.MethodDelete, "/dns/loc123/project/proj123/zone/zone321/recordset/rs322") + client := mockBuilder(). + Route("DELETE /dns/loc123/project/proj123/zone/zone321/recordset/rs322", nil). + Build(t) err := client.DeleteRecordset(t.Context(), "zone321", "rs322") require.NoError(t, err) } func TestClient_GetRecords(t *testing.T) { - client := setupTest(t, http.MethodGet, "/dns/loc123/project/proj123/zone/321/recordset/322/record", respFromFile("record.json")) + client := mockBuilder(). + Route("GET /dns/loc123/project/proj123/zone/321/recordset/322/record", + servermock.ResponseFromFixture("record.json")). + Build(t) records, err := client.GetRecords(t.Context(), "321", "322") require.NoError(t, err) @@ -84,8 +110,11 @@ func TestClient_CreateRecord(t *testing.T) { Content: "value", } - client := setupTest(t, http.MethodPost, "/dns/loc123/project/proj123/zone/z123/recordset/rs325/record", - hasReqBody(expectedReqBody), respFromFile("createRecord.json")) + client := mockBuilder(). + Route("POST /dns/loc123/project/proj123/zone/z123/recordset/rs325/record", + servermock.ResponseFromFixture("createRecord.json"), + servermock.CheckRequestJSONBodyFromStruct(expectedReqBody)). + Build(t) rs, err := client.CreateRecord(t.Context(), "z123", "rs325", "value") require.NoError(t, err) @@ -95,14 +124,20 @@ func TestClient_CreateRecord(t *testing.T) { } func TestClient_DeleteRecord(t *testing.T) { - client := setupTest(t, http.MethodDelete, "/dns/loc123/project/proj123/zone/321/recordset/322/record/323") + client := mockBuilder(). + Route("DELETE /dns/loc123/project/proj123/zone/321/recordset/322/record/323", + servermock.ResponseFromFixture("createRecord.json")). + Build(t) err := client.DeleteRecord(t.Context(), "321", "322", "323") require.NoError(t, err) } func TestClient_FindZone(t *testing.T) { - client := setupTest(t, http.MethodGet, "/dns/loc123/project/proj123/zone", respFromFile("zones.json")) + client := mockBuilder(). + Route("GET /dns/loc123/project/proj123/zone", + servermock.ResponseFromFixture("zones.json")). + Build(t) zone, err := client.FindZone(t.Context(), "example.com") require.NoError(t, err) @@ -119,7 +154,10 @@ func TestClient_FindZone(t *testing.T) { } func TestClient_GetZones(t *testing.T) { - client := setupTest(t, http.MethodGet, "/dns/loc123/project/proj123/zone", respFromFile("zones.json")) + client := mockBuilder(). + Route("GET /dns/loc123/project/proj123/zone", + servermock.ResponseFromFixture("zones.json")). + Build(t) zones, err := client.GetZones(t.Context()) require.NoError(t, err) @@ -143,77 +181,3 @@ func TestClient_GetZones(t *testing.T) { assert.Equal(t, expected, zones) } - -func setupTest(t *testing.T, method, path string, handlers ...assertHandler) *Client { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.Handle(path, http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - if len(handlers) != 0 { - for _, handler := range handlers { - code, err := handler(rw, req) - if err != nil { - http.Error(rw, err.Error(), code) - return - } - } - } - })) - - passport := &Passport{ - SubjectID: "/iam/project/proj123/sa/xxxxxxx", - } - - client, err := NewClient(server.URL, "loc123", passport) - require.NoError(t, err) - - client.signer = signerMock{} - - return client -} - -type assertHandler func(http.ResponseWriter, *http.Request) (int, error) - -func hasReqBody(v any) assertHandler { - return func(rw http.ResponseWriter, req *http.Request) (int, error) { - reqBody, err := io.ReadAll(req.Body) - if err != nil { - return http.StatusBadRequest, err - } - - marshal, err := json.Marshal(v) - if err != nil { - return http.StatusInternalServerError, err - } - - if !bytes.Equal(marshal, bytes.TrimSpace(reqBody)) { - return http.StatusBadRequest, fmt.Errorf("invalid request body, got: %s, expect: %s", string(reqBody), string(marshal)) - } - - return http.StatusOK, nil - } -} - -func respFromFile(fixtureName string) assertHandler { - return func(rw http.ResponseWriter, req *http.Request) (int, error) { - file, err := os.Open(filepath.Join(".", "fixtures", fixtureName)) - if err != nil { - return http.StatusInternalServerError, err - } - - _, err = io.Copy(rw, file) - if err != nil { - return http.StatusInternalServerError, err - } - - return http.StatusOK, nil - } -} diff --git a/providers/dns/infomaniak/internal/client_test.go b/providers/dns/infomaniak/internal/client_test.go index 5c2d93202..566ed9c34 100644 --- a/providers/dns/infomaniak/internal/client_test.go +++ b/providers/dns/infomaniak/internal/client_test.go @@ -1,64 +1,34 @@ package internal import ( - "bytes" - "fmt" - "io" - "net/http" "net/http/httptest" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := New(OAuthStaticAccessToken(server.Client(), "token"), server.URL) + if err != nil { + return nil, err + } - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client, err := New(OAuthStaticAccessToken(server.Client(), "token"), server.URL) - require.NoError(t, err) - - return client, mux + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("Bearer token")) } func TestClient_CreateDNSRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/1/domain/666/dns/record", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } - - if req.Header.Get("Authorization") != "Bearer token" { - http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return - } - - raw, err := io.ReadAll(req.Body) - if err != nil { - http.Error(rw, err.Error(), http.StatusBadRequest) - return - } - defer func() { _ = req.Body.Close() }() - - if string(bytes.TrimSpace(raw)) != `{"source":"foo","type":"TXT","ttl":60,"target":"txtxtxttxt"}` { - http.Error(rw, fmt.Sprintf("invalid request body: %s", string(raw)), http.StatusBadRequest) - return - } - - response := `{"result":"success","data": "123"}` - - _, err = rw.Write([]byte(response)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder(). + Route("POST /1/domain/666/dns/record", + servermock.RawStringResponse(`{"result":"success","data": "123"}`), + servermock.CheckRequestJSONBodyFromFile("create_dns_record-request.json")). + Build(t) domain := &DNSDomain{ ID: 666, @@ -79,53 +49,13 @@ func TestClient_CreateDNSRecord(t *testing.T) { } func TestClient_GetDomainByName(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/1/product", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } - - if req.Header.Get("Authorization") != "Bearer token" { - http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return - } - - serviceName := req.URL.Query().Get("service_name") - if serviceName != "domain" { - http.Error(rw, fmt.Sprintf("invalid service_name: %s", serviceName), http.StatusBadRequest) - return - } - - customerName := req.URL.Query().Get("customer_name") - if customerName == "" { - http.Error(rw, fmt.Sprintf("invalid customer_name: %s", customerName), http.StatusBadRequest) - return - } - - response := ` - { - "result": "success", - "data": [ - { - "id": 123, - "customer_name": "two.three.example.com" - }, - { - "id": 456, - "customer_name": "three.example.com" - } - ] - } - ` - - _, err := rw.Write([]byte(response)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder(). + Route("GET /1/product", + servermock.ResponseFromFixture("get_domain_name.json"), + servermock.CheckQueryParameter().Strict(). + WithRegexp("customer_name", `.+\.example\.com`). + With("service_name", "domain")). + Build(t) domain, err := client.GetDomainByName(t.Context(), "one.two.three.example.com.") require.NoError(t, err) @@ -135,25 +65,10 @@ func TestClient_GetDomainByName(t *testing.T) { } func TestClient_DeleteDNSRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/1/domain/123/dns/record/456", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodDelete { - http.Error(rw, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } - - if req.Header.Get("Authorization") != "Bearer token" { - http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return - } - - _, err := rw.Write([]byte((`{"result":"success"}`))) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder(). + Route("DELETE /1/domain/123/dns/record/456", + servermock.RawStringResponse(`{"result":"success"}`)). + Build(t) err := client.DeleteDNSRecord(t.Context(), 123, "456") require.NoError(t, err) diff --git a/providers/dns/infomaniak/internal/fixtures/create_dns_record-request.json b/providers/dns/infomaniak/internal/fixtures/create_dns_record-request.json new file mode 100644 index 000000000..7e00434f1 --- /dev/null +++ b/providers/dns/infomaniak/internal/fixtures/create_dns_record-request.json @@ -0,0 +1,6 @@ +{ + "source": "foo", + "type": "TXT", + "ttl": 60, + "target": "txtxtxttxt" +} diff --git a/providers/dns/infomaniak/internal/fixtures/get_domain_name.json b/providers/dns/infomaniak/internal/fixtures/get_domain_name.json new file mode 100644 index 000000000..d431cc0d7 --- /dev/null +++ b/providers/dns/infomaniak/internal/fixtures/get_domain_name.json @@ -0,0 +1,13 @@ +{ + "result": "success", + "data": [ + { + "id": 123, + "customer_name": "two.three.example.com" + }, + { + "id": 456, + "customer_name": "three.example.com" + } + ] +} diff --git a/providers/dns/internal/active24/client_test.go b/providers/dns/internal/active24/client_test.go index d92ec574d..ad2a8126b 100644 --- a/providers/dns/internal/active24/client_test.go +++ b/providers/dns/internal/active24/client_test.go @@ -1,59 +1,41 @@ package active24 import ( - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" "time" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, pattern string, status int, filename string) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient("example.com", "user", "secret") + if err != nil { + return nil, err + } - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) - mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) { - if filename == "" { - rw.WriteHeader(status) - return - } - - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = file.Close() }() - - rw.WriteHeader(status) - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - client, err := NewClient("example.com", "user", "secret") - require.NoError(t, err) - - client.HTTPClient = server.Client() - client.baseURL, _ = url.Parse(server.URL) - - return client + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithRegexp("Authorization", `Basic .+`). + WithRegexp("Date", `\d+-\d+-\d+T\d{2}:\d{2}:\d{2}.*`). + With("Accept-Language", "en_us")) } func TestClient_GetServices(t *testing.T) { - client := setupTest(t, "GET /v1/user/self/service", http.StatusOK, "services.json") + client := mockBuilder(). + Route("GET /v1/user/self/service", + servermock.ResponseFromFixture("services.json")). + Build(t) services, err := client.GetServices(t.Context()) require.NoError(t, err) @@ -83,14 +65,21 @@ func TestClient_GetServices(t *testing.T) { } func TestClient_GetServices_errors(t *testing.T) { - client := setupTest(t, "GET /v1/user/self/service", http.StatusUnauthorized, "error_v1.json") + client := mockBuilder(). + Route("GET /v1/user/self/service", + servermock.ResponseFromFixture("error_v1.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) _, err := client.GetServices(t.Context()) require.EqualError(t, err, "401: No username or password.") } func TestClient_GetRecords(t *testing.T) { - client := setupTest(t, "GET /v2/service/aaa/dns/record", http.StatusOK, "records.json") + client := mockBuilder(). + Route("GET /v2/service/aaa/dns/record", + servermock.ResponseFromFixture("records.json")). + Build(t) filter := RecordFilter{ Name: "example.com", @@ -115,7 +104,11 @@ func TestClient_GetRecords(t *testing.T) { } func TestClient_GetRecords_errors(t *testing.T) { - client := setupTest(t, "GET /v2/service/aaa/dns/record", http.StatusForbidden, "error_403.json") + client := mockBuilder(). + Route("GET /v2/service/aaa/dns/record", + servermock.ResponseFromFixture("error_403.json"). + WithStatusCode(http.StatusForbidden)). + Build(t) filter := RecordFilter{ Name: "example.com", @@ -128,28 +121,44 @@ func TestClient_GetRecords_errors(t *testing.T) { } func TestClient_CreateRecord(t *testing.T) { - client := setupTest(t, "POST /v2/service/aaa/dns/record", http.StatusNoContent, "") + client := mockBuilder(). + Route("POST /v2/service/aaa/dns/record", + servermock.Noop(). + WithStatusCode(http.StatusNoContent)). + Build(t) err := client.CreateRecord(t.Context(), "aaa", Record{}) require.NoError(t, err) } func TestClient_CreateRecord_errors(t *testing.T) { - client := setupTest(t, "POST /v2/service/aaa/dns/record", http.StatusForbidden, "error_403.json") + client := mockBuilder(). + Route("POST /v2/service/aaa/dns/record", + servermock.ResponseFromFixture("error_403.json"). + WithStatusCode(http.StatusForbidden)). + Build(t) err := client.CreateRecord(t.Context(), "aaa", Record{}) require.EqualError(t, err, "403: /errors/httpException: This action is unauthorized.") } func TestClient_DeleteRecord(t *testing.T) { - client := setupTest(t, "DELETE /v2/service/aaa/dns/record/123", http.StatusNoContent, "") + client := mockBuilder(). + Route("DELETE /v2/service/aaa/dns/record/123", + servermock.Noop(). + WithStatusCode(http.StatusNoContent)). + Build(t) err := client.DeleteRecord(t.Context(), "aaa", "123") require.NoError(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { - client := setupTest(t, "DELETE /v2/service/aaa/dns/record/123", http.StatusForbidden, "error_403.json") + client := mockBuilder(). + Route("DELETE /v2/service/aaa/dns/record/123", + servermock.ResponseFromFixture("error_403.json"). + WithStatusCode(http.StatusForbidden)). + Build(t) err := client.DeleteRecord(t.Context(), "aaa", "123") require.EqualError(t, err, "403: /errors/httpException: This action is unauthorized.") diff --git a/providers/dns/internal/hostingde/client_test.go b/providers/dns/internal/hostingde/client_test.go index c4090ec5c..b735509c0 100644 --- a/providers/dns/internal/hostingde/client_test.go +++ b/providers/dns/internal/hostingde/client_test.go @@ -1,69 +1,30 @@ package hostingde import ( - "bytes" "encoding/json" - "fmt" - "io" - "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, pattern string, handler http.HandlerFunc) *Client { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - +func setupClient(server *httptest.Server) (*Client, error) { client := NewClient("secret") client.HTTPClient = server.Client() client.BaseURL, _ = url.Parse(server.URL) - mux.HandleFunc(pattern, handler) - - return client -} - -func writeFixture(rw http.ResponseWriter, filename string) { - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, _ = io.Copy(rw, file) + return client, nil } func TestClient_ListZoneConfigs(t *testing.T) { - client := setupTest(t, "/zoneConfigsFind", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - raw, err := io.ReadAll(req.Body) - if err != nil { - http.Error(rw, err.Error(), http.StatusBadRequest) - return - } - - body := string(bytes.TrimSpace(raw)) - if body != `{"authToken":"secret","filter":{"field":"zoneName","value":"example.com"},"limit":1,"page":1}` { - http.Error(rw, fmt.Sprintf("unexpected body: got %s", body), http.StatusBadRequest) - return - } - - writeFixture(rw, "zoneConfigsFind.json") - }) + client := servermock.NewBuilder[*Client](setupClient). + Route("POST /zoneConfigsFind", + servermock.ResponseFromFixture("zoneConfigsFind.json"), + servermock.CheckRequestJSONBodyFromFile("zoneConfigsFind-request.json")). + Build(t) zonesFind := ZoneConfigsFindRequest{ Filter: Filter{Field: "zoneName", Value: "example.com"}, @@ -108,14 +69,10 @@ func TestClient_ListZoneConfigs(t *testing.T) { } func TestClient_ListZoneConfigs_error(t *testing.T) { - client := setupTest(t, "/zoneConfigsFind", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - writeFixture(rw, "zoneConfigsFind_error.json") - }) + client := servermock.NewBuilder[*Client](setupClient). + Route("POST /zoneConfigsFind", + servermock.ResponseFromFixture("zoneConfigsFind_error.json")). + Build(t) zonesFind := ZoneConfigsFindRequest{ Filter: Filter{Field: "zoneName", Value: "example.com"}, @@ -128,26 +85,11 @@ func TestClient_ListZoneConfigs_error(t *testing.T) { } func TestClient_UpdateZone(t *testing.T) { - client := setupTest(t, "/zoneUpdate", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - raw, err := io.ReadAll(req.Body) - if err != nil { - http.Error(rw, err.Error(), http.StatusBadRequest) - return - } - - body := string(bytes.TrimSpace(raw)) - if body != `{"authToken":"secret","zoneConfig":{"id":"123","accountId":"456","status":"s","name":"n","nameUnicode":"u","masterIp":"m","type":"t","emailAddress":"e","zoneTransferWhitelist":["a","b"],"lastChangeDate":"l","dnsServerGroupId":"g","dnsSecMode":"m","soaValues":{"refresh":1,"retry":2,"expire":3,"ttl":4,"negativeTtl":5}},"recordsToAdd":null,"recordsToDelete":[{"name":"_acme-challenge.example.com","type":"TXT","content":"\"txt\""}]}` { - http.Error(rw, fmt.Sprintf("unexpected body: got %s", body), http.StatusBadRequest) - return - } - - writeFixture(rw, "zoneUpdate.json") - }) + client := servermock.NewBuilder[*Client](setupClient). + Route("POST /zoneUpdate", + servermock.ResponseFromFixture("zoneUpdate.json"), + servermock.CheckRequestJSONBodyFromFile("zoneUpdate-request.json")). + Build(t) request := ZoneUpdateRequest{ ZoneConfig: ZoneConfig{ @@ -220,14 +162,10 @@ func TestClient_UpdateZone(t *testing.T) { } func TestClient_UpdateZone_error(t *testing.T) { - client := setupTest(t, "/zoneUpdate", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - writeFixture(rw, "zoneUpdate_error.json") - }) + client := servermock.NewBuilder[*Client](setupClient). + Route("POST /zoneUpdate", + servermock.ResponseFromFixture("zoneUpdate_error.json")). + Build(t) request := ZoneUpdateRequest{ ZoneConfig: ZoneConfig{ diff --git a/providers/dns/internal/hostingde/fixtures/zoneConfigsFind-request.json b/providers/dns/internal/hostingde/fixtures/zoneConfigsFind-request.json new file mode 100644 index 000000000..eb552d9eb --- /dev/null +++ b/providers/dns/internal/hostingde/fixtures/zoneConfigsFind-request.json @@ -0,0 +1,9 @@ +{ + "authToken": "secret", + "filter": { + "field": "zoneName", + "value": "example.com" + }, + "limit": 1, + "page": 1 +} diff --git a/providers/dns/internal/hostingde/fixtures/zoneUpdate-request.json b/providers/dns/internal/hostingde/fixtures/zoneUpdate-request.json new file mode 100644 index 000000000..38b1be50d --- /dev/null +++ b/providers/dns/internal/hostingde/fixtures/zoneUpdate-request.json @@ -0,0 +1,35 @@ +{ + "authToken": "secret", + "zoneConfig": { + "id": "123", + "accountId": "456", + "status": "s", + "name": "n", + "nameUnicode": "u", + "masterIp": "m", + "type": "t", + "emailAddress": "e", + "zoneTransferWhitelist": [ + "a", + "b" + ], + "lastChangeDate": "l", + "dnsServerGroupId": "g", + "dnsSecMode": "m", + "soaValues": { + "refresh": 1, + "retry": 2, + "expire": 3, + "ttl": 4, + "negativeTtl": 5 + } + }, + "recordsToAdd": null, + "recordsToDelete": [ + { + "name": "_acme-challenge.example.com", + "type": "TXT", + "content": "\"txt\"" + } + ] +} diff --git a/providers/dns/internal/rimuhosting/client_test.go b/providers/dns/internal/rimuhosting/client_test.go index 90a574b3a..6ee9ea3f7 100644 --- a/providers/dns/internal/rimuhosting/client_test.go +++ b/providers/dns/internal/rimuhosting/client_test.go @@ -2,63 +2,42 @@ package rimuhosting import ( "encoding/xml" - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - +func setupClient(server *httptest.Server) (*Client, error) { client := NewClient("apikeyvaluehere") client.BaseURL = server.URL client.HTTPClient = server.Client() - return client, mux + return client, nil } func TestClient_FindTXTRecords(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) { - query := req.URL.Query() - - var fixture string - switch query.Get("name") { - case "example.com": - fixture = "./fixtures/find_records.xml" - case "**.example.com": - fixture = "./fixtures/find_records_pattern.xml" - default: - fixture = "./fixtures/find_records_empty.xml" - } - - err := writeResponse(rw, fixture) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - testCases := []struct { desc string domain string + response string + query url.Values expected []Record }{ { - desc: "simple", - domain: "example.com", + desc: "simple", + domain: "example.com", + response: "find_records.xml", + query: url.Values{ + "name": []string{"example.com"}, + "type": []string{"TXT"}, + "action": []string{"QUERY"}, + "api_key": []string{"apikeyvaluehere"}, + }, expected: []Record{ { Name: "example.org", @@ -70,8 +49,15 @@ func TestClient_FindTXTRecords(t *testing.T) { }, }, { - desc: "pattern", - domain: "**.example.com", + desc: "pattern", + domain: "**.example.com", + response: "find_records_pattern.xml", + query: url.Values{ + "name": []string{"**.example.com"}, + "type": []string{"TXT"}, + "action": []string{"QUERY"}, + "api_key": []string{"apikeyvaluehere"}, + }, expected: []Record{ { Name: "_test.example.org", @@ -92,12 +78,26 @@ func TestClient_FindTXTRecords(t *testing.T) { { desc: "empty", domain: "empty.com", + response: "find_records_empty.xml", + query: url.Values{ + "name": []string{"empty.com"}, + "type": []string{"TXT"}, + "action": []string{"QUERY"}, + "api_key": []string{"apikeyvaluehere"}, + }, expected: nil, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /", + servermock.ResponseFromFixture(test.response), + servermock.CheckQueryParameter().Strict(). + WithValues(test.query)). + Build(t) + records, err := client.FindTXTRecords(t.Context(), test.domain) require.NoError(t, err) @@ -107,54 +107,42 @@ func TestClient_FindTXTRecords(t *testing.T) { } func TestClient_DoActions(t *testing.T) { - type expected struct { - Query string - Resp *DNSAPIResult - Error string - } - testCases := []struct { desc string actions []ActionParameter - fixture string - expected expected + query url.Values + response string + expected *DNSAPIResult }{ - { - desc: "SET error", - actions: []ActionParameter{ - NewAddRecordAction("example.com", "txttxtx", 0), - }, - fixture: "./fixtures/add_record_error.xml", - expected: expected{ - Query: "action=SET&api_key=apikeyvaluehere&name=example.com&type=TXT&value=txttxtx", - Error: "ERROR: No zone found for example.com", - }, - }, { desc: "SET simple", actions: []ActionParameter{ NewAddRecordAction("example.org", "txttxtx", 0), }, - fixture: "./fixtures/add_record.xml", - expected: expected{ - Query: "action=SET&api_key=apikeyvaluehere&name=example.org&type=TXT&value=txttxtx", - Resp: &DNSAPIResult{ - XMLName: xml.Name{Space: "", Local: "dnsapi_result"}, - IsOk: "OK:", - ResultCounts: ResultCounts{Added: "1", Changed: "0", Unchanged: "0", Deleted: "0"}, - Actions: Actions{ - Action: Action{ - Action: "SET", - Host: "example.org", - Type: "TXT", - Records: []Record{{ - Name: "example.org", - Type: "TXT", - Content: "txttxtx", - TTL: "3600 seconds", - Priority: "0", - }}, - }, + response: "add_record.xml", + query: url.Values{ + "action": []string{"SET"}, + "name": []string{"example.org"}, + "type": []string{"TXT"}, + "value": []string{"txttxtx"}, + "api_key": []string{"apikeyvaluehere"}, + }, + expected: &DNSAPIResult{ + XMLName: xml.Name{Space: "", Local: "dnsapi_result"}, + IsOk: "OK:", + ResultCounts: ResultCounts{Added: "1", Changed: "0", Unchanged: "0", Deleted: "0"}, + Actions: Actions{ + Action: Action{ + Action: "SET", + Host: "example.org", + Type: "TXT", + Records: []Record{{ + Name: "example.org", + Type: "TXT", + Content: "txttxtx", + TTL: "3600 seconds", + Priority: "0", + }}, }, }, }, @@ -165,69 +153,72 @@ func TestClient_DoActions(t *testing.T) { NewAddRecordAction("example.org", "txttxtx", 0), NewAddRecordAction("example.org", "sample", 0), }, - fixture: "./fixtures/add_record_same_domain.xml", - expected: expected{ - Query: "action[0]=SET&action[1]=SET&api_key=apikeyvaluehere&name[0]=example.org&name[1]=example.org&ttl[0]=0&ttl[1]=0&type[0]=TXT&type[1]=TXT&value[0]=txttxtx&value[1]=sample", - Resp: &DNSAPIResult{ - XMLName: xml.Name{Space: "", Local: "dnsapi_result"}, - IsOk: "OK:", - ResultCounts: ResultCounts{Added: "2", Changed: "0", Unchanged: "0", Deleted: "0"}, - Actions: Actions{ - Action: Action{ - Action: "SET", - Host: "example.org", - Type: "TXT", - Records: []Record{ - { - Name: "example.org", - Type: "TXT", - Content: "txttxtx", - TTL: "0 seconds", - Priority: "0", - }, - { - Name: "example.org", - Type: "TXT", - Content: "sample", - TTL: "0 seconds", - Priority: "0", - }, + response: "add_record_same_domain.xml", + query: url.Values{ + "api_key": []string{"apikeyvaluehere"}, + "action[0]": []string{"SET"}, + "name[0]": []string{"example.org"}, + "ttl[0]": []string{"0"}, + "type[0]": []string{"TXT"}, + "value[0]": []string{"txttxtx"}, + "action[1]": []string{"SET"}, + "name[1]": []string{"example.org"}, + "ttl[1]": []string{"0"}, + "type[1]": []string{"TXT"}, + "value[1]": []string{"sample"}, + }, + expected: &DNSAPIResult{ + XMLName: xml.Name{Space: "", Local: "dnsapi_result"}, + IsOk: "OK:", + ResultCounts: ResultCounts{Added: "2", Changed: "0", Unchanged: "0", Deleted: "0"}, + Actions: Actions{ + Action: Action{ + Action: "SET", + Host: "example.org", + Type: "TXT", + Records: []Record{ + { + Name: "example.org", + Type: "TXT", + Content: "txttxtx", + TTL: "0 seconds", + Priority: "0", + }, + { + Name: "example.org", + Type: "TXT", + Content: "sample", + TTL: "0 seconds", + Priority: "0", }, }, }, }, }, }, - { - desc: "DELETE error", - actions: []ActionParameter{ - NewDeleteRecordAction("example.com", "txttxtx"), - }, - fixture: "./fixtures/delete_record_error.xml", - expected: expected{ - Query: "action=DELETE&api_key=apikeyvaluehere&name=example.com&type=TXT&value=txttxtx", - Error: "ERROR: No zone found for example.com", - }, - }, { desc: "DELETE nothing", actions: []ActionParameter{ NewDeleteRecordAction("example.org", "nothing"), }, - fixture: "./fixtures/delete_record_nothing.xml", - expected: expected{ - Query: "action=DELETE&api_key=apikeyvaluehere&name=example.org&type=TXT&value=nothing", - Resp: &DNSAPIResult{ - XMLName: xml.Name{Space: "", Local: "dnsapi_result"}, - IsOk: "OK:", - ResultCounts: ResultCounts{Added: "0", Changed: "0", Unchanged: "0", Deleted: "0"}, - Actions: Actions{ - Action: Action{ - Action: "DELETE", - Host: "example.org", - Type: "TXT", - Records: nil, - }, + response: "delete_record_nothing.xml", + query: url.Values{ + "action": []string{"DELETE"}, + "name": []string{"example.org"}, + "type": []string{"TXT"}, + "value": []string{"nothing"}, + "api_key": []string{"apikeyvaluehere"}, + }, + expected: &DNSAPIResult{ + XMLName: xml.Name{Space: "", Local: "dnsapi_result"}, + IsOk: "OK:", + ResultCounts: ResultCounts{Added: "0", Changed: "0", Unchanged: "0", Deleted: "0"}, + Actions: Actions{ + Action: Action{ + Action: "DELETE", + Host: "example.org", + Type: "TXT", + Records: nil, }, }, }, @@ -237,26 +228,30 @@ func TestClient_DoActions(t *testing.T) { actions: []ActionParameter{ NewDeleteRecordAction("example.org", "txttxtx"), }, - fixture: "./fixtures/delete_record.xml", - expected: expected{ - Query: "action=DELETE&api_key=apikeyvaluehere&name=example.org&type=TXT&value=txttxtx", - Resp: &DNSAPIResult{ - XMLName: xml.Name{Space: "", Local: "dnsapi_result"}, - IsOk: "OK:", - ResultCounts: ResultCounts{Added: "0", Changed: "0", Unchanged: "0", Deleted: "1"}, - Actions: Actions{ - Action: Action{ - Action: "DELETE", - Host: "example.org", - Type: "TXT", - Records: []Record{{ - Name: "example.org", - Type: "TXT", - Content: "txttxtx", - TTL: "3600 seconds", - Priority: "0", - }}, - }, + response: "delete_record.xml", + query: url.Values{ + "action": []string{"DELETE"}, + "name": []string{"example.org"}, + "type": []string{"TXT"}, + "value": []string{"txttxtx"}, + "api_key": []string{"apikeyvaluehere"}, + }, + expected: &DNSAPIResult{ + XMLName: xml.Name{Space: "", Local: "dnsapi_result"}, + IsOk: "OK:", + ResultCounts: ResultCounts{Added: "0", Changed: "0", Unchanged: "0", Deleted: "1"}, + Actions: Actions{ + Action: Action{ + Action: "DELETE", + Host: "example.org", + Type: "TXT", + Records: []Record{{ + Name: "example.org", + Type: "TXT", + Content: "txttxtx", + TTL: "3600 seconds", + Priority: "0", + }}, }, }, }, @@ -265,52 +260,73 @@ func TestClient_DoActions(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) { - query, err := url.QueryUnescape(req.URL.RawQuery) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - if test.expected.Query != query { - http.Error(rw, fmt.Sprintf("invalid query: %s", query), http.StatusBadRequest) - return - } - - if test.expected.Error != "" { - rw.WriteHeader(http.StatusInternalServerError) - } - - err = writeResponse(rw, test.fixture) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /", + servermock.ResponseFromFixture(test.response), + servermock.CheckQueryParameter().Strict(). + WithValues(test.query)). + Build(t) resp, err := client.DoActions(t.Context(), test.actions...) - if test.expected.Error != "" { - require.EqualError(t, err, test.expected.Error) - return - } - require.NoError(t, err) - assert.Equal(t, test.expected.Resp, resp) + assert.Equal(t, test.expected, resp) }) } } -func writeResponse(rw io.Writer, filename string) error { - file, err := os.Open(filename) - if err != nil { - return err +func TestClient_DoActions_error(t *testing.T) { + testCases := []struct { + desc string + actions []ActionParameter + query url.Values + response string + expected string + }{ + { + desc: "SET error", + actions: []ActionParameter{ + NewAddRecordAction("example.com", "txttxtx", 0), + }, + response: "add_record_error.xml", + query: url.Values{ + "action": []string{"SET"}, + "name": []string{"example.com"}, + "type": []string{"TXT"}, + "value": []string{"txttxtx"}, + "api_key": []string{"apikeyvaluehere"}, + }, + expected: "ERROR: No zone found for example.com", + }, + { + desc: "DELETE error", + actions: []ActionParameter{ + NewDeleteRecordAction("example.com", "txttxtx"), + }, + response: "delete_record_error.xml", + query: url.Values{ + "action": []string{"DELETE"}, + "name": []string{"example.com"}, + "type": []string{"TXT"}, + "value": []string{"txttxtx"}, + "api_key": []string{"apikeyvaluehere"}, + }, + expected: "ERROR: No zone found for example.com", + }, } - defer func() { _ = file.Close() }() + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /", + servermock.ResponseFromFixture(test.response). + WithStatusCode(http.StatusInternalServerError), + servermock.CheckQueryParameter().Strict(). + WithValues(test.query)). + Build(t) - _, err = io.Copy(rw, file) - return err + _, err := client.DoActions(t.Context(), test.actions...) + require.EqualError(t, err, test.expected) + }) + } } diff --git a/providers/dns/internal/selectel/client_test.go b/providers/dns/internal/selectel/client_test.go index d0a2f8cf0..d67a4b3bf 100644 --- a/providers/dns/internal/selectel/client_test.go +++ b/providers/dns/internal/selectel/client_test.go @@ -1,50 +1,29 @@ package selectel import ( - "encoding/json" - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - +func setupClient(server *httptest.Server) (*Client, error) { client := NewClient("token") client.BaseURL, _ = url.Parse(server.URL) client.HTTPClient = server.Client() - return client, mux + return client, nil } func TestClient_ListRecords(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/123/records/", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - fixture := "./fixtures/list_records.json" - - err := writeResponse(rw, fixture) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := servermock.NewBuilder[*Client](setupClient, + servermock.CheckHeader().WithJSONHeaders()). + Route("GET /123/records/", servermock.ResponseFromFixture("list_records.json")). + Build(t) records, err := client.ListRecords(t.Context(), 123) require.NoError(t, err) @@ -59,21 +38,12 @@ func TestClient_ListRecords(t *testing.T) { } func TestClient_ListRecords_error(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/123/records/", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - rw.WriteHeader(http.StatusUnauthorized) - err := writeResponse(rw, "./fixtures/error.json") - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := servermock.NewBuilder[*Client](setupClient, + servermock.CheckHeader().WithJSONHeaders(). + With(tokenHeader, "token")). + Route("GET /123/records/", + servermock.ResponseFromFixture("error.json").WithStatusCode(http.StatusUnauthorized)). + Build(t) records, err := client.ListRecords(t.Context(), 123) @@ -82,40 +52,16 @@ func TestClient_ListRecords_error(t *testing.T) { } func TestClient_GetDomainByName(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/sub.sub.example.org", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - rw.WriteHeader(http.StatusNotFound) - }) - - mux.HandleFunc("/sub.example.org", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - rw.WriteHeader(http.StatusNotFound) - }) - - mux.HandleFunc("/example.org", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - fixture := "./fixtures/domains.json" - - err := writeResponse(rw, fixture) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := servermock.NewBuilder[*Client](setupClient, + servermock.CheckHeader().WithJSONHeaders(). + With(tokenHeader, "token")). + Route("GET /sub.sub.example.org", + servermock.Noop().WithStatusCode(http.StatusNotFound)). + Route("GET /sub.example.org", + servermock.Noop().WithStatusCode(http.StatusNotFound)). + Route("GET /example.org", + servermock.ResponseFromFixture("domains.json")). + Build(t) domain, err := client.GetDomainByName(t.Context(), "sub.sub.example.org") require.NoError(t, err) @@ -129,30 +75,13 @@ func TestClient_GetDomainByName(t *testing.T) { } func TestClient_AddRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/123/records/", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - rec := Record{} - - err := json.NewDecoder(req.Body).Decode(&rec) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - rec.ID = 456 - - err = json.NewEncoder(rw).Encode(rec) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := servermock.NewBuilder[*Client](setupClient, + servermock.CheckHeader().WithJSONHeaders(). + With(tokenHeader, "token")). + Route("POST /123/records/", + servermock.ResponseFromFixture("add_record.json"), + servermock.CheckRequestJSONBodyFromFile("add_record-request.json")). + Build(t) record, err := client.AddRecord(t.Context(), 123, Record{ Name: "example.org", @@ -177,27 +106,12 @@ func TestClient_AddRecord(t *testing.T) { } func TestClient_DeleteRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodDelete { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - }) + client := servermock.NewBuilder[*Client](setupClient, + servermock.CheckHeader().WithJSONHeaders(). + With(tokenHeader, "token")). + Route("DELETE /123/records/456", nil). + Build(t) err := client.DeleteRecord(t.Context(), 123, 456) require.NoError(t, err) } - -func writeResponse(rw io.Writer, filename string) error { - file, err := os.Open(filename) - if err != nil { - return err - } - - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - return err -} diff --git a/providers/dns/internal/selectel/fixtures/add_record-request.json b/providers/dns/internal/selectel/fixtures/add_record-request.json new file mode 100644 index 000000000..c65d3d267 --- /dev/null +++ b/providers/dns/internal/selectel/fixtures/add_record-request.json @@ -0,0 +1,7 @@ +{ + "name": "example.org", + "type": "TXT", + "ttl": 60, + "email": "email@example.org", + "content": "txttxttxttxt" +} diff --git a/providers/dns/internal/selectel/fixtures/add_record.json b/providers/dns/internal/selectel/fixtures/add_record.json new file mode 100644 index 000000000..18a436707 --- /dev/null +++ b/providers/dns/internal/selectel/fixtures/add_record.json @@ -0,0 +1,8 @@ +{ + "id": 456, + "name": "example.org", + "type": "TXT", + "ttl": 60, + "email": "email@example.org", + "content": "txttxttxttxt" +} diff --git a/providers/dns/internetbs/internal/client_test.go b/providers/dns/internetbs/internal/client_test.go index d0b94ad4e..4532426d5 100644 --- a/providers/dns/internetbs/internal/client_test.go +++ b/providers/dns/internetbs/internal/client_test.go @@ -2,14 +2,13 @@ package internal import ( "fmt" - "io" - "net/http" "net/http/httptest" "net/url" "os" "strconv" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -21,8 +20,33 @@ const ( testPassword = "testpass" ) +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient(testAPIKey, testPassword) + client.baseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() + + return client, nil + }, + servermock.CheckHeader(). + WithContentTypeFromURLEncoded(), + ) +} + func TestClient_AddRecord(t *testing.T) { - client := setupTest(t, "/Domain/DnsRecord/Add", "./fixtures/Domain_DnsRecord_Add_SUCCESS.json") + client := mockBuilder(). + Route("POST /Domain/DnsRecord/Add", + servermock.ResponseFromFixture("Domain_DnsRecord_Add_SUCCESS.json"), + servermock.CheckForm().Strict(). + With("fullrecordname", "www.example.com"). + With("ttl", "36000"). + With("type", "TXT"). + With("value", "xxx"). + With("password", testPassword). + With("apiKey", testAPIKey). + With("ResponseFormat", "JSON")). + Build(t) query := RecordQuery{ FullRecordName: "www.example.com", @@ -36,7 +60,10 @@ func TestClient_AddRecord(t *testing.T) { } func TestClient_AddRecord_error(t *testing.T) { - client := setupTest(t, "/Domain/DnsRecord/Add", "./fixtures/Domain_DnsRecord_Add_FAILURE.json") + client := mockBuilder(). + Route("POST /Domain/DnsRecord/Add", + servermock.ResponseFromFixture("Domain_DnsRecord_Add_FAILURE.json")). + Build(t) query := RecordQuery{ FullRecordName: "www.example.com.", @@ -81,7 +108,16 @@ func TestClient_AddRecord_integration(t *testing.T) { } func TestClient_RemoveRecord(t *testing.T) { - client := setupTest(t, "/Domain/DnsRecord/Remove", "./fixtures/Domain_DnsRecord_Remove_SUCCESS.json") + client := mockBuilder(). + Route("POST /Domain/DnsRecord/Remove", + servermock.ResponseFromFixture("Domain_DnsRecord_Remove_SUCCESS.json"), + servermock.CheckForm().Strict(). + With("fullrecordname", "www.example.com"). + With("type", "TXT"). + With("password", testPassword). + With("apiKey", testAPIKey). + With("ResponseFormat", "JSON")). + Build(t) query := RecordQuery{ FullRecordName: "www.example.com", @@ -93,7 +129,10 @@ func TestClient_RemoveRecord(t *testing.T) { } func TestClient_RemoveRecord_error(t *testing.T) { - client := setupTest(t, "/Domain/DnsRecord/Remove", "./fixtures/Domain_DnsRecord_Remove_FAILURE.json") + client := mockBuilder(). + Route("POST /Domain/DnsRecord/Remove", + servermock.ResponseFromFixture("Domain_DnsRecord_Remove_FAILURE.json")). + Build(t) query := RecordQuery{ FullRecordName: "www.example.com.", @@ -125,7 +164,15 @@ func TestClient_RemoveRecord_integration(t *testing.T) { } func TestClient_ListRecords(t *testing.T) { - client := setupTest(t, "/Domain/DnsRecord/List", "./fixtures/Domain_DnsRecord_List_SUCCESS.json") + client := mockBuilder(). + Route("POST /Domain/DnsRecord/List", + servermock.ResponseFromFixture("Domain_DnsRecord_List_SUCCESS.json"), + servermock.CheckForm().Strict(). + With("Domain", "example.com"). + With("password", testPassword). + With("apiKey", testAPIKey). + With("ResponseFormat", "JSON")). + Build(t) query := ListRecordQuery{ Domain: "example.com", @@ -177,7 +224,10 @@ func TestClient_ListRecords(t *testing.T) { } func TestClient_ListRecords_error(t *testing.T) { - client := setupTest(t, "/Domain/DnsRecord/List", "./fixtures/Domain_DnsRecord_List_FAILURE.json") + client := mockBuilder(). + Route("POST /Domain/DnsRecord/List", + servermock.ResponseFromFixture("Domain_DnsRecord_List_FAILURE.json")). + Build(t) query := ListRecordQuery{ Domain: "www.example.com", @@ -208,51 +258,3 @@ func TestClient_ListRecords_integration(t *testing.T) { fmt.Println(record) } } - -func setupTest(t *testing.T, path, filename string) *Client { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc(path, testHandler(filename)) - - client := NewClient(testAPIKey, testPassword) - client.baseURL, _ = url.Parse(server.URL) - - return client -} - -func testHandler(filename string) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - if req.FormValue("apiKey") != testAPIKey { - http.Error(rw, `{"transactid":"d46d812569acdb8b39c3933ec4351e79","status":"FAILURE","message":"Invalid API key and\/or Password","code":107002}`, http.StatusOK) - return - } - - if req.FormValue("password") != testPassword { - http.Error(rw, `{"transactid":"d46d812569acdb8b39c3933ec4351e79","status":"FAILURE","message":"Invalid API key and\/or Password","code":107002}`, http.StatusOK) - return - } - - file, err := os.Open(filename) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - } -} diff --git a/providers/dns/internetbs/internal/fixtures/auth_error.json b/providers/dns/internetbs/internal/fixtures/auth_error.json new file mode 100644 index 000000000..a40a0ef5e --- /dev/null +++ b/providers/dns/internetbs/internal/fixtures/auth_error.json @@ -0,0 +1,6 @@ +{ + "transactid": "d46d812569acdb8b39c3933ec4351e79", + "status": "FAILURE", + "message": "Invalid API key and\/or Password", + "code": 107002 +} diff --git a/providers/dns/ionos/internal/client.go b/providers/dns/ionos/internal/client.go index 8b37d5f1c..b51e003f7 100644 --- a/providers/dns/ionos/internal/client.go +++ b/providers/dns/ionos/internal/client.go @@ -17,6 +17,9 @@ import ( // defaultBaseURL represents the API endpoint to call. const defaultBaseURL = "https://api.hosting.ionos.com/dns" +// APIKeyHeader API key header. +const APIKeyHeader = "X-Api-Key" + // Client Ionos API client. type Client struct { apiKey string @@ -119,7 +122,7 @@ func (c *Client) RemoveRecord(ctx context.Context, zoneID, recordID string) erro } func (c *Client) do(req *http.Request, result any) error { - req.Header.Set("X-API-Key", c.apiKey) + req.Header.Set(APIKeyHeader, c.apiKey) resp, err := c.HTTPClient.Do(req) if err != nil { diff --git a/providers/dns/ionos/internal/client_test.go b/providers/dns/ionos/internal/client_test.go index 6a36dfde7..008d153bc 100644 --- a/providers/dns/ionos/internal/client_test.go +++ b/providers/dns/ionos/internal/client_test.go @@ -1,24 +1,38 @@ package internal import ( - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestClient_ListZones(t *testing.T) { - client, mux := setupTest(t) +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient("secret") + if err != nil { + return nil, err + } - mux.HandleFunc("/v1/zones", mockHandler(http.MethodGet, http.StatusOK, "list_zones.json")) + client.BaseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() + + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(), + servermock.CheckHeader().With(APIKeyHeader, "secret")) +} + +func TestClient_ListZones(t *testing.T) { + client := mockBuilder(). + Route("GET /v1/zones", + servermock.ResponseFromFixture("list_zones.json")). + Build(t) zones, err := client.ListZones(t.Context()) require.NoError(t, err) @@ -33,9 +47,11 @@ func TestClient_ListZones(t *testing.T) { } func TestClient_ListZones_error(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/v1/zones", mockHandler(http.MethodGet, http.StatusUnauthorized, "list_zones_error.json")) + client := mockBuilder(). + Route("GET /v1/zones", + servermock.ResponseFromFixture("list_zones_error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) zones, err := client.ListZones(t.Context()) require.Error(t, err) @@ -48,9 +64,10 @@ func TestClient_ListZones_error(t *testing.T) { } func TestClient_GetRecords(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/v1/zones/azone01", mockHandler(http.MethodGet, http.StatusOK, "get_records.json")) + client := mockBuilder(). + Route("GET /v1/zones/azone01", + servermock.ResponseFromFixture("get_records.json")). + Build(t) records, err := client.GetRecords(t.Context(), "azone01", nil) require.NoError(t, err) @@ -66,9 +83,11 @@ func TestClient_GetRecords(t *testing.T) { } func TestClient_GetRecords_error(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/v1/zones/azone01", mockHandler(http.MethodGet, http.StatusUnauthorized, "get_records_error.json")) + client := mockBuilder(). + Route("GET /v1/zones/azone01", + servermock.ResponseFromFixture("get_records_error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) records, err := client.GetRecords(t.Context(), "azone01", nil) require.Error(t, err) @@ -81,18 +100,20 @@ func TestClient_GetRecords_error(t *testing.T) { } func TestClient_RemoveRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/v1/zones/azone01/records/arecord01", mockHandler(http.MethodDelete, http.StatusOK, "")) + client := mockBuilder(). + Route("DELETE /v1/zones/azone01/records/arecord01", nil). + Build(t) err := client.RemoveRecord(t.Context(), "azone01", "arecord01") require.NoError(t, err) } func TestClient_RemoveRecord_error(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/v1/zones/azone01/records/arecord01", mockHandler(http.MethodDelete, http.StatusInternalServerError, "remove_record_error.json")) + client := mockBuilder(). + Route("DELETE /v1/zones/azone01/records/arecord01", + servermock.ResponseFromFixture("remove_record_error.json"). + WithStatusCode(http.StatusInternalServerError)). + Build(t) err := client.RemoveRecord(t.Context(), "azone01", "arecord01") require.Error(t, err) @@ -103,9 +124,9 @@ func TestClient_RemoveRecord_error(t *testing.T) { } func TestClient_ReplaceRecords(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/v1/zones/azone01", mockHandler(http.MethodPatch, http.StatusOK, "")) + client := mockBuilder(). + Route("PATCH /v1/zones/azone01", nil). + Build(t) records := []Record{{ ID: "22af3414-abbe-9e11-5df5-66fbe8e334b4", @@ -119,9 +140,11 @@ func TestClient_ReplaceRecords(t *testing.T) { } func TestClient_ReplaceRecords_error(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/v1/zones/azone01", mockHandler(http.MethodPatch, http.StatusBadRequest, "replace_records_error.json")) + client := mockBuilder(). + Route("PATCH /v1/zones/azone01", + servermock.ResponseFromFixture("replace_records_error.json"). + WithStatusCode(http.StatusBadRequest)). + Build(t) records := []Record{{ ID: "22af3414-abbe-9e11-5df5-66fbe8e334b4", @@ -137,47 +160,3 @@ func TestClient_ReplaceRecords_error(t *testing.T) { assert.ErrorAs(t, err, &cErr) assert.Equal(t, http.StatusBadRequest, cErr.StatusCode) } - -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client, err := NewClient("secret") - require.NoError(t, err) - - client.BaseURL, _ = url.Parse(server.URL) - - return client, mux -} - -func mockHandler(method string, statusCode int, filename string) func(http.ResponseWriter, *http.Request) { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - if filename == "" { - rw.WriteHeader(statusCode) - return - } - - file, err := os.Open(filepath.FromSlash(path.Join("./fixtures", filename))) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - rw.WriteHeader(statusCode) - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - } -} diff --git a/providers/dns/ipv64/internal/client_test.go b/providers/dns/ipv64/internal/client_test.go index 8f97d8ff2..ba5ede9fc 100644 --- a/providers/dns/ipv64/internal/client_test.go +++ b/providers/dns/ipv64/internal/client_test.go @@ -1,66 +1,33 @@ package internal import ( - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const testAPIKey = "secret" -func setupTest(t *testing.T, handler http.HandlerFunc) *Client { - t.Helper() - - server := httptest.NewServer(handler) - +func setupClient(server *httptest.Server) (*Client, error) { client := NewClient(OAuthStaticAccessToken(server.Client(), testAPIKey)) client.HTTPClient = server.Client() client.baseURL, _ = url.Parse(server.URL) - return client -} - -func testHandler(method, filename string, statusCode int) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - auth := req.Header.Get("Authorization") - if auth != "Bearer "+testAPIKey { - http.Error(rw, fmt.Sprintf("invalid API key: %s", auth), http.StatusUnauthorized) - return - } - - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = file.Close() }() - - rw.WriteHeader(statusCode) - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - } + return client, nil } func TestClient_GetDomains(t *testing.T) { - client := setupTest(t, testHandler(http.MethodGet, "get_domains.json", http.StatusOK)) + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /api", + servermock.ResponseFromFixture("get_domains.json"), + servermock.CheckQueryParameter().Strict(). + With("get_domains", "")). + Build(t) domains, err := client.GetDomains(t.Context()) require.NoError(t, err) @@ -111,7 +78,11 @@ func TestClient_GetDomains(t *testing.T) { } func TestClient_GetDomains_error(t *testing.T) { - client := setupTest(t, testHandler(http.MethodGet, "error.json", http.StatusUnauthorized)) + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /api", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) domains, err := client.GetDomains(t.Context()) require.Error(t, err) @@ -120,28 +91,53 @@ func TestClient_GetDomains_error(t *testing.T) { } func TestClient_AddRecord(t *testing.T) { - client := setupTest(t, testHandler(http.MethodPost, "add_record.json", http.StatusCreated)) + client := servermock.NewBuilder[*Client](setupClient, + servermock.CheckHeader().WithContentTypeFromURLEncoded()). + Route("POST /api", + servermock.ResponseFromFixture("add_record.json"). + WithStatusCode(http.StatusCreated), + servermock.CheckForm().Strict(). + With("add_record", "lego.ipv64.net"). + With("content", "value"). + With("praefix", "_acme-challenge"). + With("type", "TXT"), + ). + Build(t) err := client.AddRecord(t.Context(), "lego.ipv64.net", "_acme-challenge", "TXT", "value") require.NoError(t, err) } func TestClient_AddRecord_error(t *testing.T) { - client := setupTest(t, testHandler(http.MethodPost, "add_record-error.json", http.StatusBadRequest)) + client := servermock.NewBuilder[*Client](setupClient). + Route("POST /api", + servermock.ResponseFromFixture("add_record-error.json"). + WithStatusCode(http.StatusBadRequest)). + Build(t) err := client.AddRecord(t.Context(), "lego.ipv64.net", "_acme-challenge", "TXT", "value") require.Error(t, err) } func TestClient_DeleteRecord(t *testing.T) { - client := setupTest(t, testHandler(http.MethodDelete, "del_record.json", http.StatusAccepted)) + client := servermock.NewBuilder[*Client](setupClient, + servermock.CheckHeader().WithContentTypeFromURLEncoded()). + Route("DELETE /api", + // the query parameters can be checked because the Go server ignores the body of a DELETE request. + servermock.ResponseFromFixture("del_record.json"). + WithStatusCode(http.StatusAccepted)). + Build(t) err := client.DeleteRecord(t.Context(), "lego.ipv64.net", "_acme-challenge", "TXT", "value") require.NoError(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { - client := setupTest(t, testHandler(http.MethodDelete, "del_record-error.json", http.StatusBadRequest)) + client := servermock.NewBuilder[*Client](setupClient). + Route("DELETE /api", + servermock.ResponseFromFixture("del_record-error.json"). + WithStatusCode(http.StatusBadRequest)). + Build(t) err := client.DeleteRecord(t.Context(), "lego.ipv64.net", "_acme-challenge", "TXT", "value") require.Error(t, err) diff --git a/providers/dns/iwantmyname/internal/client_test.go b/providers/dns/iwantmyname/internal/client_test.go index 39dca6dca..c25eb56ef 100644 --- a/providers/dns/iwantmyname/internal/client_test.go +++ b/providers/dns/iwantmyname/internal/client_test.go @@ -7,72 +7,32 @@ import ( "net/url" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) -func checkParameter(query url.Values, key, expected string) error { - if query.Get(key) != expected { - return fmt.Errorf("%s: want %s got %s", key, expected, query.Get(key)) - } - return nil -} - -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - +func setupClient(server *httptest.Server) (*Client, error) { client := NewClient("user", "secret") client.HTTPClient = server.Client() client.baseURL, _ = url.Parse(server.URL) - return client, mux + return client, nil } func TestClient_Do(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - username, password, ok := req.BasicAuth() - if !ok { - http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return - } - - if username != "user" { - http.Error(rw, fmt.Sprintf("username: want %s got %s", username, "user"), http.StatusUnauthorized) - return - } - - if password != "secret" { - http.Error(rw, fmt.Sprintf("password: want %s got %s", password, "secret"), http.StatusUnauthorized) - return - } - - query := req.URL.Query() - - values := map[string]string{ - "hostname": "example.com", - "type": "TXT", - "value": "data", - "ttl": "120", - } - - for k, v := range values { - err := checkParameter(query, k, v) - if err != nil { - http.Error(rw, err.Error(), http.StatusBadRequest) - return - } - } - }) + client := servermock.NewBuilder[*Client](setupClient, + servermock.CheckHeader(). + WithBasicAuth("user", "secret"), + ). + Route("POST /", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + fmt.Println(req) + }), + servermock.CheckQueryParameter().Strict(). + With("hostname", "example.com"). + With("ttl", "120"). + With("type", "TXT"). + With("value", "data")). + Build(t) record := Record{ Hostname: "example.com", diff --git a/providers/dns/joker/internal/dmapi/client_test.go b/providers/dns/joker/internal/dmapi/client_test.go index b7a294e09..5b6d68740 100644 --- a/providers/dns/joker/internal/dmapi/client_test.go +++ b/providers/dns/joker/internal/dmapi/client_test.go @@ -7,6 +7,7 @@ import ( "net/url" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -23,14 +24,17 @@ const ( serverErrorUsername = "error" ) -func setupTest(t *testing.T) (*http.ServeMux, string) { - t.Helper() +func mockBuilder(auth AuthInfo) *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient(auth) + client.BaseURL = server.URL + client.HTTPClient = server.Client() - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - return mux, server.URL + return client, nil + }, + servermock.CheckHeader(). + WithContentTypeFromURLEncoded()) } func TestClient_GetZone(t *testing.T) { @@ -70,29 +74,24 @@ func TestClient_GetZone(t *testing.T) { }, } - mux, serverURL := setupTest(t) + client := mockBuilder(AuthInfo{APIKey: "12345"}). + Route("POST /dns-zone-get", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + authSid := req.FormValue("auth-sid") + domain := req.FormValue("domain") - mux.HandleFunc("/dns-zone-get", func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, http.MethodPost, r.Method) - - authSid := r.FormValue("auth-sid") - domain := r.FormValue("domain") - - switch { - case authSid == correctAPIKey && domain == "known": - _, _ = io.WriteString(w, "Status-Code: 0\nStatus-Text: OK\n\n"+testZone) - case authSid == incorrectAPIKey || (authSid == correctAPIKey && domain == "unknown"): - _, _ = io.WriteString(w, "Status-Code: 2202\nStatus-Text: Authorization error") - default: - http.NotFound(w, r) - } - }) + switch { + case authSid == correctAPIKey && domain == "known": + _, _ = io.WriteString(rw, "Status-Code: 0\nStatus-Text: OK\n\n"+testZone) + case authSid == incorrectAPIKey || (authSid == correctAPIKey && domain == "unknown"): + _, _ = io.WriteString(rw, "Status-Code: 2202\nStatus-Text: Authorization error") + default: + http.NotFound(rw, req) + } + })). + Build(t) for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - client := NewClient(AuthInfo{APIKey: "12345"}) - client.BaseURL = serverURL - response, err := client.GetZone(mockContext(t, test.authSid), test.domain) if test.expectedError { require.Error(t, err) diff --git a/providers/dns/joker/internal/dmapi/identity_test.go b/providers/dns/joker/internal/dmapi/identity_test.go index b84321096..d2a80f2e6 100644 --- a/providers/dns/joker/internal/dmapi/identity_test.go +++ b/providers/dns/joker/internal/dmapi/identity_test.go @@ -5,7 +5,6 @@ import ( "fmt" "io" "net/http" - "net/http/httptest" "sync/atomic" "testing" "time" @@ -58,27 +57,22 @@ func TestClient_login_apikey(t *testing.T) { }, } - mux, serverURL := setupTest(t) - - mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, http.MethodPost, r.Method) - - switch r.FormValue("api-key") { - case correctAPIKey: - _, _ = io.WriteString(w, "Status-Code: 0\nStatus-Text: OK\nAuth-Sid: 123\n\ncom\nnet") - case incorrectAPIKey: - _, _ = io.WriteString(w, "Status-Code: 2200\nStatus-Text: Authentication error") - case serverErrorAPIKey: - http.NotFound(w, r) - default: - _, _ = io.WriteString(w, "Status-Code: 2202\nStatus-Text: OK\n\ncom\nnet") - } - }) - for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - client := NewClient(AuthInfo{APIKey: test.apiKey}) - client.BaseURL = serverURL + client := mockBuilder(AuthInfo{APIKey: test.apiKey}). + Route("POST /login", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + switch req.FormValue("api-key") { + case correctAPIKey: + _, _ = io.WriteString(rw, "Status-Code: 0\nStatus-Text: OK\nAuth-Sid: 123\n\ncom\nnet") + case incorrectAPIKey: + _, _ = io.WriteString(rw, "Status-Code: 2200\nStatus-Text: Authentication error") + case serverErrorAPIKey: + http.NotFound(rw, req) + default: + _, _ = io.WriteString(rw, "Status-Code: 2202\nStatus-Text: OK\n\ncom\nnet") + } + })). + Build(t) response, err := client.login(t.Context()) if test.expectedError { @@ -133,27 +127,22 @@ func TestClient_login_username(t *testing.T) { }, } - mux, serverURL := setupTest(t) - - mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, http.MethodPost, r.Method) - - switch r.FormValue("username") { - case correctUsername: - _, _ = io.WriteString(w, "Status-Code: 0\nStatus-Text: OK\nAuth-Sid: 123\n\ncom\nnet") - case incorrectUsername: - _, _ = io.WriteString(w, "Status-Code: 2200\nStatus-Text: Authentication error") - case serverErrorUsername: - http.NotFound(w, r) - default: - _, _ = io.WriteString(w, "Status-Code: 2202\nStatus-Text: OK\n\ncom\nnet") - } - }) - for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - client := NewClient(AuthInfo{Username: test.username, Password: test.password}) - client.BaseURL = serverURL + client := mockBuilder(AuthInfo{Username: test.username, Password: test.password}). + Route("POST /login", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + switch req.FormValue("username") { + case correctUsername: + _, _ = io.WriteString(rw, "Status-Code: 0\nStatus-Text: OK\nAuth-Sid: 123\n\ncom\nnet") + case incorrectUsername: + _, _ = io.WriteString(rw, "Status-Code: 2200\nStatus-Text: Authentication error") + case serverErrorUsername: + http.NotFound(rw, req) + default: + _, _ = io.WriteString(rw, "Status-Code: 2202\nStatus-Text: OK\n\ncom\nnet") + } + })). + Build(t) response, err := client.login(t.Context()) if test.expectedError { @@ -197,25 +186,21 @@ func TestClient_logout(t *testing.T) { }, } - mux, serverURL := setupTest(t) - - mux.HandleFunc("/logout", func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, http.MethodPost, r.Method) - - switch r.FormValue("auth-sid") { - case correctAPIKey: - _, _ = io.WriteString(w, "Status-Code: 0\nStatus-Text: OK\n") - case incorrectAPIKey: - _, _ = io.WriteString(w, "Status-Code: 2200\nStatus-Text: Authentication error") - default: - http.NotFound(w, r) - } - }) - for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - client := NewClient(AuthInfo{APIKey: "12345"}) - client.BaseURL = serverURL + client := mockBuilder(AuthInfo{APIKey: "12345"}). + Route("POST /logout", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + switch req.FormValue("auth-sid") { + case correctAPIKey: + _, _ = io.WriteString(rw, "Status-Code: 0\nStatus-Text: OK\n") + case incorrectAPIKey: + _, _ = io.WriteString(rw, "Status-Code: 2200\nStatus-Text: Authentication error") + default: + http.NotFound(rw, req) + } + })). + Build(t) + client.token = &Token{SessionID: test.authSid} response, err := client.Logout(mockContext(t, test.authSid)) @@ -231,29 +216,21 @@ func TestClient_logout(t *testing.T) { } func TestClient_CreateAuthenticatedContext(t *testing.T) { - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - id := atomic.Int32{} id.Add(100) - mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, http.MethodPost, r.Method) + client := mockBuilder(AuthInfo{Username: correctUsername, Password: "secret"}). + Route("POST /login", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + switch req.FormValue("username") { + case correctUsername: + _, _ = fmt.Fprintf(rw, "Status-Code: 0\nStatus-Text: OK\nAuth-Sid: %d\n\ncom\nnet", id.Load()) + id.Add(100) - switch r.FormValue("username") { - case correctUsername: - _, _ = fmt.Fprintf(w, "Status-Code: 0\nStatus-Text: OK\nAuth-Sid: %d\n\ncom\nnet", id.Load()) - id.Add(100) - - default: - _, _ = io.WriteString(w, "Status-Code: 2200\nStatus-Text: Authentication error") - } - }) - - client := NewClient(AuthInfo{Username: correctUsername, Password: "secret"}) - client.HTTPClient = server.Client() - client.BaseURL = server.URL + default: + _, _ = io.WriteString(rw, "Status-Code: 2200\nStatus-Text: Authentication error") + } + })). + Build(t) ctx, err := client.CreateAuthenticatedContext(t.Context()) require.NoError(t, err) diff --git a/providers/dns/joker/internal/svc/client_test.go b/providers/dns/joker/internal/svc/client_test.go index a396f67e5..a6cb299e4 100644 --- a/providers/dns/joker/internal/svc/client_test.go +++ b/providers/dns/joker/internal/svc/client_test.go @@ -1,51 +1,39 @@ package svc import ( - "fmt" - "io" - "net/http" "net/http/httptest" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("test", "secret") + client.BaseURL = server.URL + client.HTTPClient = server.Client() - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := NewClient("test", "secret") - client.BaseURL = server.URL - client.HTTPClient = server.Client() - - return client, mux + return client, nil + }, + servermock.CheckHeader(). + WithContentTypeFromURLEncoded()) } func TestClient_Send(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - all, _ := io.ReadAll(req.Body) - - if string(all) != "label=_acme-challenge&password=secret&type=TXT&username=test&value=123&zone=example.com" { - http.Error(rw, fmt.Sprintf("invalid request: %q", string(all)), http.StatusBadRequest) - return - } - - _, err := rw.Write([]byte("OK: 1 inserted, 0 deleted")) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder(). + Route("POST /", + servermock.RawStringResponse("OK: 1 inserted, 0 deleted"), + servermock.CheckForm().Strict(). + With("zone", "example.com"). + With("label", "_acme-challenge"). + With("type", "TXT"). + With("value", "123"). + With("username", "test"). + With("password", "secret"), + ). + Build(t) zone := "example.com" label := "_acme-challenge" @@ -56,27 +44,18 @@ func TestClient_Send(t *testing.T) { } func TestClient_Send_empty(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - all, _ := io.ReadAll(req.Body) - - if string(all) != "label=_acme-challenge&password=secret&type=TXT&username=test&value=&zone=example.com" { - http.Error(rw, fmt.Sprintf("invalid request: %q", string(all)), http.StatusBadRequest) - return - } - - _, err := rw.Write([]byte("OK: 1 inserted, 0 deleted")) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder(). + Route("POST /", + servermock.RawStringResponse("OK: 1 inserted, 0 deleted"), + servermock.CheckForm().Strict(). + With("zone", "example.com"). + With("label", "_acme-challenge"). + With("type", "TXT"). + With("value", ""). + With("username", "test"). + With("password", "secret"), + ). + Build(t) zone := "example.com" label := "_acme-challenge" diff --git a/providers/dns/liara/internal/client_test.go b/providers/dns/liara/internal/client_test.go index 233a4bc2b..57ac7e8b3 100644 --- a/providers/dns/liara/internal/client_test.go +++ b/providers/dns/liara/internal/client_test.go @@ -1,25 +1,34 @@ package internal import ( - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const apiKey = "key" -func TestClient_GetRecords(t *testing.T) { - client, mux := setupTest(t) +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient(OAuthStaticAccessToken(server.Client(), apiKey)) + client.baseURL, _ = url.Parse(server.URL) - mux.HandleFunc("/api/v1/zones/example.com/dns-records", testHandler("./RecordsResponse.json", http.MethodGet, http.StatusOK)) + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("Bearer "+apiKey)) +} + +func TestClient_GetRecords(t *testing.T) { + client := mockBuilder(). + Route("GET /api/v1/zones/example.com/dns-records", servermock.ResponseFromFixture("RecordsResponse.json")). + Build(t) records, err := client.GetRecords(t.Context(), "example.com") require.NoError(t, err) @@ -41,9 +50,9 @@ func TestClient_GetRecords(t *testing.T) { } func TestClient_GetRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/api/v1/zones/example.com/dns-records/123", testHandler("./RecordResponse.json", http.MethodGet, http.StatusOK)) + client := mockBuilder(). + Route("GET /api/v1/zones/example.com/dns-records/123", servermock.ResponseFromFixture("RecordResponse.json")). + Build(t) record, err := client.GetRecord(t.Context(), "example.com", "123") require.NoError(t, err) @@ -63,9 +72,12 @@ func TestClient_GetRecord(t *testing.T) { } func TestClient_CreateRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/api/v1/zones/example.com/dns-records", testHandler("./RecordResponse.json", http.MethodPost, http.StatusCreated)) + client := mockBuilder(). + Route("POST /api/v1/zones/example.com/dns-records", + servermock.ResponseFromFixture("RecordResponse.json"). + WithStatusCode(http.StatusCreated), + servermock.CheckRequestJSONBody(`{"name":"string","type":"string","ttl":3600,"contents":[{"text":"string"}]}`)). + Build(t) data := Record{ Type: "string", @@ -97,76 +109,34 @@ func TestClient_CreateRecord(t *testing.T) { } func TestClient_DeleteRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/api/v1/zones/example.com/dns-records/123", func(rw http.ResponseWriter, req *http.Request) { - rw.WriteHeader(http.StatusNoContent) - }) + client := mockBuilder(). + Route("DELETE /api/v1/zones/example.com/dns-records/123", + servermock.Noop(). + WithStatusCode(http.StatusNoContent)). + Build(t) err := client.DeleteRecord(t.Context(), "example.com", "123") require.NoError(t, err) } func TestClient_DeleteRecord_NotFound_Response(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/api/v1/zones/example.com/dns-records/123", func(rw http.ResponseWriter, req *http.Request) { - rw.WriteHeader(http.StatusNotFound) - }) + client := mockBuilder(). + Route("DELETE /api/v1/zones/example.com/dns-records/123", + servermock.Noop(). + WithStatusCode(http.StatusNotFound)). + Build(t) err := client.DeleteRecord(t.Context(), "example.com", "123") require.NoError(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/api/v1/zones/example.com/dns-records/123", testHandler("./error.json", http.MethodDelete, http.StatusUnauthorized)) + client := mockBuilder(). + Route("DELETE /api/v1/zones/example.com/dns-records/123", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) err := client.DeleteRecord(t.Context(), "example.com", "123") - require.Error(t, err) -} - -func testHandler(filename, method string, statusCode int) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - auth := req.Header.Get("Authorization") - if auth != "Bearer "+apiKey { - http.Error(rw, "invalid Authorization header", http.StatusUnauthorized) - return - } - - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = file.Close() }() - - rw.WriteHeader(statusCode) - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - } -} - -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := NewClient(OAuthStaticAccessToken(server.Client(), apiKey)) - client.baseURL, _ = url.Parse(server.URL) - - return client, mux + require.EqualError(t, err, "[status code: 401] Unauthorized: Invalid token missing header") } diff --git a/providers/dns/lightsail/lightsail_test.go b/providers/dns/lightsail/lightsail_test.go index 4a11f6eb4..010e794a9 100644 --- a/providers/dns/lightsail/lightsail_test.go +++ b/providers/dns/lightsail/lightsail_test.go @@ -1,6 +1,7 @@ package lightsail import ( + "net/http/httptest" "os" "testing" @@ -9,6 +10,7 @@ import ( "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/lightsail" "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -30,20 +32,6 @@ var envTest = tester.NewEnvTest( WithDomain(EnvDNSZone). WithLiveTestRequirements(envAwsAccessKeyID, envAwsSecretAccessKey, EnvDNSZone) -func makeProvider(serverURL string) *DNSProvider { - config := aws.Config{ - Credentials: credentials.NewStaticCredentialsProvider("abc", "123", " "), - Region: "mock-region", - BaseEndpoint: aws.String(serverURL), - RetryMaxAttempts: 1, - } - - return &DNSProvider{ - client: lightsail.NewFromConfig(config), - config: NewDefaultConfig(), - } -} - func TestCredentialsFromEnv(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() @@ -68,13 +56,20 @@ func TestCredentialsFromEnv(t *testing.T) { } func TestDNSProvider_Present(t *testing.T) { - mockResponses := map[string]MockResponse{ - "/": {StatusCode: 200, Body: ""}, - } - - serverURL := newMockServer(t, mockResponses) - - provider := makeProvider(serverURL) + provider := servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + return &DNSProvider{ + client: lightsail.NewFromConfig(aws.Config{ + Credentials: credentials.NewStaticCredentialsProvider("abc", "123", " "), + Region: "mock-region", + BaseEndpoint: aws.String(server.URL), + RetryMaxAttempts: 1, + }), + config: NewDefaultConfig(), + }, nil + }). + Route("POST /", nil). + Build(t) domain := "example.com" keyAuth := "123456d==" diff --git a/providers/dns/lightsail/mock_server_test.go b/providers/dns/lightsail/mock_server_test.go deleted file mode 100644 index 385c80850..000000000 --- a/providers/dns/lightsail/mock_server_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package lightsail - -import ( - "fmt" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/stretchr/testify/require" -) - -// MockResponse represents a predefined response used by a mock server. -type MockResponse struct { - StatusCode int - Body string -} - -func newMockServer(t *testing.T, responses map[string]MockResponse) string { - t.Helper() - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path - resp, ok := responses[path] - if !ok { - msg := fmt.Sprintf("Requested path not found in response map: %s", path) - require.FailNow(t, msg) - } - - w.Header().Set("Content-Type", "application/xml") - w.WriteHeader(resp.StatusCode) - _, err := w.Write([]byte(resp.Body)) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - })) - - t.Cleanup(server.Close) - - time.Sleep(100 * time.Millisecond) - - return server.URL -} diff --git a/providers/dns/limacity/internal/client_test.go b/providers/dns/limacity/internal/client_test.go index 307783953..c43f12ba2 100644 --- a/providers/dns/limacity/internal/client_test.go +++ b/providers/dns/limacity/internal/client_test.go @@ -1,69 +1,36 @@ package internal import ( - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const apiKey = "secret" -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient(apiKey) + client.baseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := NewClient(apiKey) - client.baseURL, _ = url.Parse(server.URL) - - return client, mux -} - -func testHandler(filename, method string, statusCode int) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - username, key, ok := req.BasicAuth() - if username != "api" || key != apiKey || !ok { - http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return - } - - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = file.Close() }() - - rw.WriteHeader(statusCode) - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - } + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithBasicAuth("api", apiKey), + ) } func TestClient_GetDomains(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/domains.json", testHandler("get-domains.json", http.MethodGet, http.StatusOK)) + client := mockBuilder(). + Route("GET /domains.json", servermock.ResponseFromFixture("get-domains.json")). + Build(t) domains, err := client.GetDomains(t.Context()) require.NoError(t, err) @@ -79,18 +46,20 @@ func TestClient_GetDomains(t *testing.T) { } func TestClient_GetDomains_error(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/domains.json", testHandler("error.json", http.MethodGet, http.StatusBadRequest)) + client := mockBuilder(). + Route("GET /domains.json", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusBadRequest)). + Build(t) _, err := client.GetDomains(t.Context()) require.EqualError(t, err, "[status code: 400] status: invalid_resource, details: name: [muss ausgefüllt werden]") } func TestClient_GetRecords(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/domains/123/records.json", testHandler("get-records.json", http.MethodGet, http.StatusOK)) + client := mockBuilder(). + Route("GET /domains/123/records.json", servermock.ResponseFromFixture("get-records.json")). + Build(t) records, err := client.GetRecords(t.Context(), 123) require.NoError(t, err) @@ -115,18 +84,22 @@ func TestClient_GetRecords(t *testing.T) { } func TestClient_GetRecords_error(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/domains/123/records.json", testHandler("error.json", http.MethodGet, http.StatusBadRequest)) + client := mockBuilder(). + Route("GET /domains/123/records.json", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusBadRequest)). + Build(t) _, err := client.GetRecords(t.Context(), 123) require.EqualError(t, err, "[status code: 400] status: invalid_resource, details: name: [muss ausgefüllt werden]") } func TestClient_AddRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/domains/123/records.json", testHandler("ok.json", http.MethodPost, http.StatusOK)) + client := mockBuilder(). + Route("POST /domains/123/records.json", + servermock.ResponseFromFixture("ok.json"), + servermock.CheckRequestJSONBody(`{"nameserver_record":{"name":"foo","content":"bar","ttl":12,"type":"TXT"}}`)). + Build(t) record := Record{ Name: "foo", @@ -140,9 +113,11 @@ func TestClient_AddRecord(t *testing.T) { } func TestClient_AddRecord_error(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/domains/123/records.json", testHandler("error.json", http.MethodPost, http.StatusBadRequest)) + client := mockBuilder(). + Route("POST /domains/123/records.json", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusBadRequest)). + Build(t) record := Record{ Name: "foo", @@ -156,36 +131,43 @@ func TestClient_AddRecord_error(t *testing.T) { } func TestClient_UpdateRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/domains/123/records/456", testHandler("ok.json", http.MethodPut, http.StatusOK)) + client := mockBuilder(). + Route("PUT /domains/123/records/456", + servermock.ResponseFromFixture("ok.json"), + servermock.CheckRequestJSONBody(`{"nameserver_record":{}}`)). + Build(t) err := client.UpdateRecord(t.Context(), 123, 456, Record{}) require.NoError(t, err) } func TestClient_UpdateRecord_error(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/domains/123/records/456", testHandler("error.json", http.MethodPut, http.StatusBadRequest)) + client := mockBuilder(). + Route("PUT /domains/123/records/456", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusBadRequest)). + Build(t) err := client.UpdateRecord(t.Context(), 123, 456, Record{}) require.EqualError(t, err, "[status code: 400] status: invalid_resource, details: name: [muss ausgefüllt werden]") } func TestClient_DeleteRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/domains/123/records/456", testHandler("ok.json", http.MethodDelete, http.StatusOK)) + client := mockBuilder(). + Route("DELETE /domains/123/records/456", + servermock.ResponseFromFixture("ok.json")). + Build(t) err := client.DeleteRecord(t.Context(), 123, 456) require.NoError(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/domains/123/records/456", testHandler("error.json", http.MethodDelete, http.StatusBadRequest)) + client := mockBuilder(). + Route("DELETE /domains/123/records/456", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusBadRequest)). + Build(t) err := client.DeleteRecord(t.Context(), 123, 456) require.EqualError(t, err, "[status code: 400] status: invalid_resource, details: name: [muss ausgefüllt werden]") diff --git a/providers/dns/linode/linode_test.go b/providers/dns/linode/linode_test.go index a6b8041f8..08549ab7e 100644 --- a/providers/dns/linode/linode_test.go +++ b/providers/dns/linode/linode_test.go @@ -1,69 +1,20 @@ package linode import ( - "encoding/json" - "fmt" "net/http" "net/http/httptest" "os" "testing" - "time" "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/linode/linodego" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -type MockResponseMap map[string]any - var envTest = tester.NewEnvTest(EnvToken) -func setupTest(t *testing.T, responses MockResponseMap) string { - t.Helper() - - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Ensure that we support the requested action. - action := r.Method + ":" + r.URL.Path - resp, ok := responses[action] - if !ok { - http.Error(w, fmt.Sprintf("Unsupported mock action: %q", action), http.StatusInternalServerError) - return - } - - rawResponse, err := json.Marshal(resp) - if err != nil { - http.Error(w, fmt.Sprintf("Failed to JSON encode response: %v", err), http.StatusInternalServerError) - return - } - - // Send the response. - w.Header().Set("Content-Type", "application/json") - if err, ok := resp.(linodego.APIError); ok { - if err.Errors[0].Reason == "Not found" { - w.WriteHeader(http.StatusNotFound) - } else { - w.WriteHeader(http.StatusBadRequest) - } - } else { - w.WriteHeader(http.StatusOK) - } - - _, err = w.Write(rawResponse) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) - - server := httptest.NewServer(handler) - t.Cleanup(server.Close) - - time.Sleep(100 * time.Millisecond) - - return server.URL -} - func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string @@ -145,81 +96,77 @@ func TestDNSProvider_Present(t *testing.T) { defer envTest.RestoreEnv() os.Setenv(EnvToken, "testing") - p, err := NewDNSProvider() - require.NoError(t, err) - require.NotNil(t, p) - domain := "example.com" keyAuth := "dGVzdGluZw==" testCases := []struct { desc string - mockResponses MockResponseMap + builder *servermock.Builder[*DNSProvider] expectedError string }{ { desc: "Success", - mockResponses: MockResponseMap{ - "GET:/v4/domains": linodego.DomainsPagedResponse{ - PageOptions: &linodego.PageOptions{ - Pages: 1, - Results: 1, - Page: 1, - }, - Data: []linodego.Domain{{ - Domain: domain, - ID: 1234, - }}, - }, - "POST:/v4/domains/1234/records": linodego.DomainRecord{ + builder: mockBuilder(). + Route("GET /v4/domains", + servermock.JSONEncode(linodego.DomainsPagedResponse{ + PageOptions: &linodego.PageOptions{ + Pages: 1, + Results: 1, + Page: 1, + }, + Data: []linodego.Domain{{ + Domain: domain, + ID: 1234, + }}, + })). + Route("POST /v4/domains/1234/records", servermock.JSONEncode(linodego.DomainRecord{ ID: 1234, - }, - }, + })), }, { desc: "NoDomain", - mockResponses: MockResponseMap{ - "GET:/v4/domains": linodego.APIError{ - Errors: []linodego.APIErrorReason{{ - Reason: "Not found", - }}, - }, - }, + builder: mockBuilder(). + Route("GET /v4/domains", + servermock.JSONEncode(linodego.APIError{ + Errors: []linodego.APIErrorReason{{ + Reason: "Not found", + }}, + }). + WithStatusCode(http.StatusNotFound)), expectedError: "[404] Not found", }, { desc: "CreateFailed", - mockResponses: MockResponseMap{ - "GET:/v4/domains": &linodego.DomainsPagedResponse{ - PageOptions: &linodego.PageOptions{ - Pages: 1, - Results: 1, - Page: 1, - }, - Data: []linodego.Domain{{ - Domain: "example.com", - ID: 1234, - }}, - }, - "POST:/v4/domains/1234/records": linodego.APIError{ - Errors: []linodego.APIErrorReason{{ - Reason: "Failed to create domain resource", - Field: "somefield", - }}, - }, - }, + builder: mockBuilder(). + Route("GET /v4/domains", + servermock.JSONEncode(&linodego.DomainsPagedResponse{ + PageOptions: &linodego.PageOptions{ + Pages: 1, + Results: 1, + Page: 1, + }, + Data: []linodego.Domain{{ + Domain: "example.com", + ID: 1234, + }}, + })). + Route("POST /v4/domains/1234/records", + servermock.JSONEncode(linodego.APIError{ + Errors: []linodego.APIErrorReason{{ + Reason: "Failed to create domain resource", + Field: "somefield", + }}, + }). + WithStatusCode(http.StatusBadRequest)), expectedError: "[400] [somefield] Failed to create domain resource", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - serverURL := setupTest(t, test.mockResponses) + provider := test.builder.Build(t) - assert.NotNil(t, p.client) - p.client.SetBaseURL(serverURL) - - err = p.Present(domain, "", keyAuth) + err := provider.Present(domain, "", keyAuth) if test.expectedError == "" { assert.NoError(t, err) } else { @@ -233,107 +180,111 @@ func TestDNSProvider_CleanUp(t *testing.T) { defer envTest.RestoreEnv() os.Setenv(EnvToken, "testing") - p, err := NewDNSProvider() - require.NoError(t, err) - domain := "example.com" keyAuth := "dGVzdGluZw==" testCases := []struct { desc string - mockResponses MockResponseMap + builder *servermock.Builder[*DNSProvider] expectedError string }{ { desc: "Success", - mockResponses: MockResponseMap{ - "GET:/v4/domains": &linodego.DomainsPagedResponse{ - PageOptions: &linodego.PageOptions{ - Pages: 1, - Results: 1, - Page: 1, - }, - Data: []linodego.Domain{{ - Domain: "foobar.com", - ID: 1234, - }}, - }, - "GET:/v4/domains/1234/records": &linodego.DomainRecordsPagedResponse{ - PageOptions: &linodego.PageOptions{ - Pages: 1, - Results: 1, - Page: 1, - }, - Data: []linodego.DomainRecord{{ - ID: 1234, - Name: "_acme-challenge", - Target: "ElbOJKOkFWiZLQeoxf-wb3IpOsQCdvoM0y_wn0TEkxM", - Type: "TXT", - }}, - }, - "DELETE:/v4/domains/1234/records/1234": struct{}{}, - }, + builder: mockBuilder(). + Route("GET /v4/domains", + servermock.JSONEncode(&linodego.DomainsPagedResponse{ + PageOptions: &linodego.PageOptions{ + Pages: 1, + Results: 1, + Page: 1, + }, + Data: []linodego.Domain{{ + Domain: "foobar.com", + ID: 1234, + }}, + })). + Route("GET /v4/domains/1234/records", + servermock.JSONEncode(&linodego.DomainRecordsPagedResponse{ + PageOptions: &linodego.PageOptions{ + Pages: 1, + Results: 1, + Page: 1, + }, + Data: []linodego.DomainRecord{{ + ID: 1234, + Name: "_acme-challenge", + Target: "ElbOJKOkFWiZLQeoxf-wb3IpOsQCdvoM0y_wn0TEkxM", + Type: "TXT", + }}, + })). + Route("DELETE /v4/domains/1234/records/1234", + servermock.RawStringResponse("{}").WithHeader("Content-Type", "application/json")), }, { desc: "NoDomain", - mockResponses: MockResponseMap{ - "GET:/v4/domains": linodego.APIError{ - Errors: []linodego.APIErrorReason{{ - Reason: "Not found", - }}, - }, - "GET:/v4/domains/1234/records": linodego.APIError{ - Errors: []linodego.APIErrorReason{{ - Reason: "Not found", - }}, - }, - }, + builder: mockBuilder(). + Route("GET /v4/domains", + servermock.JSONEncode(linodego.APIError{ + Errors: []linodego.APIErrorReason{{ + Reason: "Not found", + }}, + }). + WithStatusCode(http.StatusNotFound)). + Route("GET /v4/domains/1234/records", + servermock.JSONEncode(linodego.APIError{ + Errors: []linodego.APIErrorReason{{ + Reason: "Not found", + }}, + }, + ). + WithStatusCode(http.StatusNotFound)), expectedError: "[404] Not found", }, { desc: "DeleteFailed", - mockResponses: MockResponseMap{ - "GET:/v4/domains": linodego.DomainsPagedResponse{ - PageOptions: &linodego.PageOptions{ - Pages: 1, - Results: 1, - Page: 1, - }, - Data: []linodego.Domain{{ - ID: 1234, - Domain: "example.com", - }}, - }, - "GET:/v4/domains/1234/records": linodego.DomainRecordsPagedResponse{ - PageOptions: &linodego.PageOptions{ - Pages: 1, - Results: 1, - Page: 1, - }, - Data: []linodego.DomainRecord{{ - ID: 1234, - Name: "_acme-challenge", - Target: "ElbOJKOkFWiZLQeoxf-wb3IpOsQCdvoM0y_wn0TEkxM", - Type: "TXT", - }}, - }, - "DELETE:/v4/domains/1234/records/1234": linodego.APIError{ - Errors: []linodego.APIErrorReason{{ - Reason: "Failed to delete domain resource", - }}, - }, - }, + builder: mockBuilder(). + Route("GET /v4/domains", + servermock.JSONEncode(linodego.DomainsPagedResponse{ + PageOptions: &linodego.PageOptions{ + Pages: 1, + Results: 1, + Page: 1, + }, + Data: []linodego.Domain{{ + ID: 1234, + Domain: "example.com", + }}, + })). + Route("GET /v4/domains/1234/records", + servermock.JSONEncode(linodego.DomainRecordsPagedResponse{ + PageOptions: &linodego.PageOptions{ + Pages: 1, + Results: 1, + Page: 1, + }, + Data: []linodego.DomainRecord{{ + ID: 1234, + Name: "_acme-challenge", + Target: "ElbOJKOkFWiZLQeoxf-wb3IpOsQCdvoM0y_wn0TEkxM", + Type: "TXT", + }}, + })). + Route("DELETE /v4/domains/1234/records/1234", + servermock.JSONEncode(linodego.APIError{ + Errors: []linodego.APIErrorReason{{ + Reason: "Failed to delete domain resource", + }}, + }). + WithStatusCode(http.StatusBadRequest)), expectedError: "[400] Failed to delete domain resource", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - serverURL := setupTest(t, test.mockResponses) + provider := test.builder.Build(t) - p.client.SetBaseURL(serverURL) - - err = p.CleanUp(domain, "", keyAuth) + err := provider.CleanUp(domain, "", keyAuth) if test.expectedError == "" { assert.NoError(t, err) } else { @@ -356,3 +307,16 @@ func TestLiveCleanUp(t *testing.T) { } // TODO implement this test } + +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder(func(server *httptest.Server) (*DNSProvider, error) { + p, err := NewDNSProvider() + if err != nil { + return nil, err + } + + p.client.SetBaseURL(server.URL) + + return p, nil + }) +} diff --git a/providers/dns/liquidweb/liquidweb_test.go b/providers/dns/liquidweb/liquidweb_test.go index a26b18e1b..b0788c7f5 100644 --- a/providers/dns/liquidweb/liquidweb_test.go +++ b/providers/dns/liquidweb/liquidweb_test.go @@ -18,22 +18,6 @@ var envTest = tester.NewEnvTest( EnvZone). WithDomain(envDomain) -func setupTest(t *testing.T, initRecs ...network.DNSRecord) *DNSProvider { - t.Helper() - - serverURL := mockAPIServer(t, initRecs) - - config := NewDefaultConfig() - config.Username = "blars" - config.Password = "tacoman" - config.BaseURL = serverURL - - provider, err := NewDNSProviderConfig(config) - require.NoError(t, err) - - return provider -} - func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string @@ -161,14 +145,14 @@ func TestNewDNSProviderConfig(t *testing.T) { } func TestDNSProvider_Present(t *testing.T) { - provider := setupTest(t) + provider := mockProvider(t) err := provider.Present("tacoman.com", "", "") require.NoError(t, err) } func TestDNSProvider_CleanUp(t *testing.T) { - provider := setupTest(t, network.DNSRecord{ + provider := mockProvider(t, network.DNSRecord{ Name: "_acme-challenge.tacoman.com", RData: "123d==", Type: "TXT", @@ -239,7 +223,7 @@ func TestDNSProvider(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - provider := setupTest(t, test.initRecs...) + provider := mockProvider(t, test.initRecs...) if test.present { err := provider.Present(test.domain, test.token, test.keyAuth) diff --git a/providers/dns/liquidweb/servermock_test.go b/providers/dns/liquidweb/servermock_test.go index 8c22595af..9cb434761 100644 --- a/providers/dns/liquidweb/servermock_test.go +++ b/providers/dns/liquidweb/servermock_test.go @@ -1,7 +1,6 @@ package liquidweb import ( - "bytes" "encoding/json" "fmt" "io" @@ -10,11 +9,12 @@ import ( "net/http/httptest" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/liquidweb/liquidweb-go/network" "github.com/liquidweb/liquidweb-go/types" ) -func mockAPIServer(t *testing.T, initRecs []network.DNSRecord) string { +func mockProvider(t *testing.T, initRecs ...network.DNSRecord) *DNSProvider { t.Helper() recs := make(map[int]network.DNSRecord) @@ -23,157 +23,137 @@ func mockAPIServer(t *testing.T, initRecs []network.DNSRecord) string { recs[int(rec.ID)] = rec } - mux := http.NewServeMux() - mux.Handle("/v1/Network/DNS/Record/delete", mockAPIDelete(recs)) - mux.Handle("/v1/Network/DNS/Record/create", mockAPICreate(recs)) - mux.Handle("/v1/Network/DNS/Zone/list", mockAPIListZones()) - mux.Handle("/bleed/Network/DNS/Record/delete", mockAPIDelete(recs)) - mux.Handle("/bleed/Network/DNS/Record/create", mockAPICreate(recs)) - mux.Handle("/bleed/Network/DNS/Zone/list", mockAPIListZones()) + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.Username = "blars" + config.Password = "tacoman" + config.BaseURL = server.URL - server := httptest.NewServer(requireBasicAuth(requireJSON(mux))) - t.Cleanup(server.Close) - - return server.URL -} - -func requireBasicAuth(next http.Handler) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - username, password, ok := r.BasicAuth() - if ok && username == "blars" && password == "tacoman" { - next.ServeHTTP(w, r) - return - } - - http.Error(w, "invalid auth", http.StatusForbidden) - } -} - -func requireJSON(next http.Handler) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - buf := &bytes.Buffer{} - - _, err := buf.ReadFrom(r.Body) - if err != nil { - http.Error(w, "malformed request - json required", http.StatusBadRequest) - return - } - - r.Body = io.NopCloser(buf) - next.ServeHTTP(w, r) - } + return NewDNSProviderConfig(config) + }, + servermock.CheckHeader(). + WithBasicAuth("blars", "tacoman"), + ). + Route("/v1/Network/DNS/Record/delete", mockAPIDelete(recs)). + Route("/v1/Network/DNS/Record/create", mockAPICreate(recs)). + Route("/v1/Network/DNS/Zone/list", mockAPIListZones()). + Route("/bleed/Network/DNS/Record/delete", mockAPIDelete(recs)). + Route("/bleed/Network/DNS/Record/create", mockAPICreate(recs)). + Route("/bleed/Network/DNS/Zone/list", mockAPIListZones()). + Build(t) } func mockAPICreate(recs map[int]network.DNSRecord) http.HandlerFunc { _, mockAPIServerZones := makeMockZones() - return func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) + return func(rw http.ResponseWriter, req *http.Request) { + body, err := io.ReadAll(req.Body) if err != nil { - http.Error(w, "invalid request", http.StatusInternalServerError) + http.Error(rw, "invalid request", http.StatusInternalServerError) return } - req := struct { + payload := struct { Params network.DNSRecord `json:"params"` }{} - if err = json.Unmarshal(body, &req); err != nil { - http.Error(w, makeEncodingError(body), http.StatusBadRequest) + if err = json.Unmarshal(body, &payload); err != nil { + http.Error(rw, makeEncodingError(body), http.StatusBadRequest) return } - req.Params.ID = types.FlexInt(rand.Intn(10000000)) - req.Params.ZoneID = types.FlexInt(mockAPIServerZones[req.Params.Name]) + payload.Params.ID = types.FlexInt(rand.Intn(10000000)) + payload.Params.ZoneID = types.FlexInt(mockAPIServerZones[payload.Params.Name]) - if _, exists := recs[int(req.Params.ID)]; exists { - http.Error(w, "dns record already exists", http.StatusTeapot) + if _, exists := recs[int(payload.Params.ID)]; exists { + http.Error(rw, "dns record already exists", http.StatusTeapot) return } - recs[int(req.Params.ID)] = req.Params + recs[int(payload.Params.ID)] = payload.Params - resp, err := json.Marshal(req.Params) + resp, err := json.Marshal(payload.Params) if err != nil { - http.Error(w, "", http.StatusInternalServerError) + http.Error(rw, "", http.StatusInternalServerError) return } - http.Error(w, string(resp), http.StatusOK) + http.Error(rw, string(resp), http.StatusOK) } } func mockAPIDelete(recs map[int]network.DNSRecord) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) + return func(rw http.ResponseWriter, req *http.Request) { + body, err := io.ReadAll(req.Body) if err != nil { - http.Error(w, "invalid request", http.StatusInternalServerError) + http.Error(rw, "invalid request", http.StatusInternalServerError) return } - req := struct { + payload := struct { Params struct { Name string `json:"name"` ID int `json:"id"` } `json:"params"` }{} - if err := json.Unmarshal(body, &req); err != nil { - http.Error(w, makeEncodingError(body), http.StatusBadRequest) + if err := json.Unmarshal(body, &payload); err != nil { + http.Error(rw, makeEncodingError(body), http.StatusBadRequest) return } - if req.Params.ID == 0 { - http.Error(w, `{"error":"","error_class":"LW::Exception::Input::Multiple","errors":[{"error":"","error_class":"LW::Exception::Input::Required","field":"id","full_message":"The required field 'id' was missing a value.","position":null}],"field":["id"],"full_message":"The following input errors occurred:\nThe required field 'id' was missing a value.","type":null}`, http.StatusOK) + if payload.Params.ID == 0 { + http.Error(rw, `{"error":"","error_class":"LW::Exception::Input::Multiple","errors":[{"error":"","error_class":"LW::Exception::Input::Required","field":"id","full_message":"The required field 'id' was missing a value.","position":null}],"field":["id"],"full_message":"The following input errors occurred:\nThe required field 'id' was missing a value.","type":null}`, http.StatusOK) return } - if _, ok := recs[req.Params.ID]; !ok { - http.Error(w, fmt.Sprintf(`{"error":"","error_class":"LW::Exception::RecordNotFound","field":"network_dns_rr","full_message":"Record 'network_dns_rr: %d' not found","input":"%d","public_message":null}`, req.Params.ID, req.Params.ID), http.StatusOK) + if _, ok := recs[payload.Params.ID]; !ok { + http.Error(rw, fmt.Sprintf(`{"error":"","error_class":"LW::Exception::RecordNotFound","field":"network_dns_rr","full_message":"Record 'network_dns_rr: %d' not found","input":"%d","public_message":null}`, payload.Params.ID, payload.Params.ID), http.StatusOK) return } - delete(recs, req.Params.ID) - http.Error(w, fmt.Sprintf("{\"deleted\":%d}", req.Params.ID), http.StatusOK) + delete(recs, payload.Params.ID) + http.Error(rw, fmt.Sprintf("{\"deleted\":%d}", payload.Params.ID), http.StatusOK) } } func mockAPIListZones() http.HandlerFunc { mockZones, mockAPIServerZones := makeMockZones() - return func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) + return func(rw http.ResponseWriter, req *http.Request) { + body, err := io.ReadAll(req.Body) if err != nil { - http.Error(w, "invalid request", http.StatusInternalServerError) + http.Error(rw, "invalid request", http.StatusInternalServerError) return } - req := struct { + payload := struct { Params struct { PageNum int `json:"page_num"` } `json:"params"` }{} - if err = json.Unmarshal(body, &req); err != nil { - http.Error(w, makeEncodingError(body), http.StatusBadRequest) + if err = json.Unmarshal(body, &payload); err != nil { + http.Error(rw, makeEncodingError(body), http.StatusBadRequest) return } switch { - case req.Params.PageNum < 1: - req.Params.PageNum = 1 - case req.Params.PageNum > len(mockZones): - req.Params.PageNum = len(mockZones) + case payload.Params.PageNum < 1: + payload.Params.PageNum = 1 + case payload.Params.PageNum > len(mockZones): + payload.Params.PageNum = len(mockZones) } - resp := mockZones[req.Params.PageNum] + resp := mockZones[payload.Params.PageNum] resp.ItemTotal = types.FlexInt(len(mockAPIServerZones)) - resp.PageNum = types.FlexInt(req.Params.PageNum) + resp.PageNum = types.FlexInt(payload.Params.PageNum) resp.PageSize = 5 resp.PageTotal = types.FlexInt(len(mockZones)) var respBody []byte if respBody, err = json.Marshal(resp); err == nil { - http.Error(w, string(respBody), http.StatusOK) + http.Error(rw, string(respBody), http.StatusOK) return } - http.Error(w, "", http.StatusInternalServerError) + http.Error(rw, "", http.StatusInternalServerError) } } diff --git a/providers/dns/loopia/internal/client_test.go b/providers/dns/loopia/internal/client_test.go index a84b7c9ad..63962b06e 100644 --- a/providers/dns/loopia/internal/client_test.go +++ b/providers/dns/loopia/internal/client_test.go @@ -2,61 +2,76 @@ package internal import ( "encoding/xml" - "fmt" - "io" "net/http" "net/http/httptest" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +func mockBuilder(password string) *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("apiuser", password) + client.BaseURL = server.URL + "/" + + return client, nil + }, + servermock.CheckHeader().WithContentType("text/xml"), + ) +} + func TestClient_AddZoneRecord(t *testing.T) { - serverResponses := map[string]string{ - addZoneRecordGoodAuth: responseOk, - addZoneRecordBadAuth: responseAuthError, - addZoneRecordNonValidDomain: responseUnknownError, - addZoneRecordEmptyResponse: "", - } - - serverURL := createFakeServer(t, serverResponses) - testCases := []struct { desc string password string domain string + request string + response string err string }{ { desc: "auth ok", password: "goodpassword", domain: exampleDomain, + request: addZoneRecordGoodAuth, + response: responseOk, }, { desc: "auth error", password: "badpassword", domain: exampleDomain, + request: addZoneRecordBadAuth, + response: responseAuthError, err: "authentication error", }, { desc: "unknown error", password: "goodpassword", domain: "badexample.com", + request: addZoneRecordNonValidDomain, + response: responseUnknownError, err: `unknown error: "UNKNOWN_ERROR"`, }, { desc: "empty response", password: "goodpassword", domain: "empty.com", + request: addZoneRecordEmptyResponse, + response: "", err: "unmarshal error: EOF", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - client := NewClient("apiuser", test.password) - client.BaseURL = serverURL + "/" + client := mockBuilder(test.password). + Route("POST /", + servermock.RawStringResponse(test.response), + servermock.CheckRequestBody(test.request)). + Build(t) err := client.AddTXTRecord(t.Context(), test.domain, exampleSubDomain, 123, "TXTrecord") if test.err == "" { @@ -70,50 +85,54 @@ func TestClient_AddZoneRecord(t *testing.T) { } func TestClient_RemoveSubdomain(t *testing.T) { - serverResponses := map[string]string{ - removeSubdomainGoodAuth: responseOk, - removeSubdomainBadAuth: responseAuthError, - removeSubdomainNonValidDomain: responseUnknownError, - removeSubdomainEmptyResponse: "", - } - - serverURL := createFakeServer(t, serverResponses) - testCases := []struct { desc string password string domain string + request string + response string err string }{ { desc: "auth ok", password: "goodpassword", domain: exampleDomain, + request: removeSubdomainGoodAuth, + response: responseOk, }, { desc: "auth error", password: "badpassword", domain: exampleDomain, + request: removeSubdomainBadAuth, + response: responseAuthError, err: "authentication error", }, { desc: "unknown error", password: "goodpassword", domain: "badexample.com", + request: removeSubdomainNonValidDomain, + response: responseUnknownError, err: `unknown error: "UNKNOWN_ERROR"`, }, { desc: "empty response", password: "goodpassword", domain: "empty.com", + request: removeSubdomainEmptyResponse, + response: "", err: "unmarshal error: EOF", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - client := NewClient("apiuser", test.password) - client.BaseURL = serverURL + "/" + client := mockBuilder(test.password). + Route("POST /", + servermock.RawStringResponse(test.response), + servermock.CheckRequestBody(test.request)). + Build(t) err := client.RemoveSubdomain(t.Context(), test.domain, exampleSubDomain) if test.err == "" { @@ -127,50 +146,54 @@ func TestClient_RemoveSubdomain(t *testing.T) { } func TestClient_RemoveZoneRecord(t *testing.T) { - serverResponses := map[string]string{ - removeRecordGoodAuth: responseOk, - removeRecordBadAuth: responseAuthError, - removeRecordNonValidDomain: responseUnknownError, - removeRecordEmptyResponse: "", - } - - serverURL := createFakeServer(t, serverResponses) - testCases := []struct { desc string password string domain string + request string + response string err string }{ { desc: "auth ok", password: "goodpassword", domain: exampleDomain, + request: removeRecordGoodAuth, + response: responseOk, }, { desc: "auth error", password: "badpassword", domain: exampleDomain, + request: removeRecordBadAuth, + response: responseAuthError, err: "authentication error", }, { desc: "uknown error", password: "goodpassword", domain: "badexample.com", + request: removeRecordNonValidDomain, + response: responseUnknownError, err: `unknown error: "UNKNOWN_ERROR"`, }, { desc: "empty response", password: "goodpassword", domain: "empty.com", + request: removeRecordEmptyResponse, + response: "", err: "unmarshal error: EOF", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - client := NewClient("apiuser", test.password) - client.BaseURL = serverURL + "/" + client := mockBuilder(test.password). + Route("POST /", + servermock.RawStringResponse(test.response), + servermock.CheckRequestBody(test.request)). + Build(t) err := client.RemoveTXTRecord(t.Context(), test.domain, exampleSubDomain, 12345678) if test.err == "" { @@ -184,14 +207,11 @@ func TestClient_RemoveZoneRecord(t *testing.T) { } func TestClient_GetZoneRecord(t *testing.T) { - serverResponses := map[string]string{ - getZoneRecords: getZoneRecordsResponse, - } - - serverURL := createFakeServer(t, serverResponses) - - client := NewClient("apiuser", "goodpassword") - client.BaseURL = serverURL + "/" + client := mockBuilder("goodpassword"). + Route("POST /", + servermock.RawStringResponse(getZoneRecordsResponse), + servermock.CheckRequestBody(getZoneRecords)). + Build(t) recordObjs, err := client.GetTXTRecords(t.Context(), exampleDomain, exampleSubDomain) require.NoError(t, err) @@ -209,23 +229,11 @@ func TestClient_GetZoneRecord(t *testing.T) { } func TestClient_rpcCall_404(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - w.WriteHeader(http.StatusNotFound) - - _, err = fmt.Fprint(w, "") - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - })) - - t.Cleanup(server.Close) + client := mockBuilder("apipassword"). + Route("POST /", + servermock.RawStringResponse(""). + WithStatusCode(http.StatusNotFound)). + Build(t) call := &methodCall{ MethodName: "dummyMethod", @@ -234,29 +242,15 @@ func TestClient_rpcCall_404(t *testing.T) { }, } - client := NewClient("apiuser", "apipassword") - client.BaseURL = server.URL + "/" - err := client.rpcCall(t.Context(), call, &responseString{}) require.EqualError(t, err, "unexpected status code: [status code: 404] body: ") } func TestClient_rpcCall_RPCError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - _, err = fmt.Fprint(w, responseRPCError) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - })) - - t.Cleanup(server.Close) + client := mockBuilder("apipassword"). + Route("POST /", + servermock.RawStringResponse(responseRPCError)). + Build(t) call := &methodCall{ MethodName: "getDomains", @@ -265,9 +259,6 @@ func TestClient_rpcCall_RPCError(t *testing.T) { }, } - client := NewClient("apiuser", "apipassword") - client.BaseURL = server.URL + "/" - err := client.rpcCall(t.Context(), call, &responseString{}) require.EqualError(t, err, "RPC Error: (201) Method signature error: 42") } @@ -300,37 +291,3 @@ func TestUnmarshallFaultyRecordObject(t *testing.T) { }) } } - -func createFakeServer(t *testing.T, serverResponses map[string]string) string { - t.Helper() - - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("Content-Type") != "text/xml" { - http.Error(w, fmt.Sprintf("invalid content type: %s", r.Header.Get("Content-Type")), http.StatusBadRequest) - return - } - - req, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - resp, ok := serverResponses[string(req)] - if !ok { - http.Error(w, "no response for request", http.StatusBadRequest) - return - } - - _, err = fmt.Fprint(w, resp) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) - - server := httptest.NewServer(handler) - t.Cleanup(server.Close) - - return server.URL -} diff --git a/providers/dns/luadns/internal/client_test.go b/providers/dns/luadns/internal/client_test.go index 1b09814ef..0a3a79e6c 100644 --- a/providers/dns/luadns/internal/client_test.go +++ b/providers/dns/luadns/internal/client_test.go @@ -1,60 +1,32 @@ package internal import ( - "fmt" - "io" - "net/http" "net/http/httptest" "net/url" - "os" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, apiToken string) (*Client, *http.ServeMux) { - t.Helper() +func mockBuilder(apiToken string) *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("me", apiToken) + client.baseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := NewClient("me", apiToken) - client.baseURL, _ = url.Parse(server.URL) - client.HTTPClient = server.Client() - - return client, mux + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithBasicAuth("me", apiToken)) } func TestClient_ListZones(t *testing.T) { - client, mux := setupTest(t, "secretA") - - mux.HandleFunc("/v1/zones", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, fmt.Sprintf("invalid method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - auth := req.Header.Get("Authorization") - if auth != "Basic bWU6c2VjcmV0QQ==" { - http.Error(rw, fmt.Sprintf("invalid authentication: %s", auth), http.StatusUnauthorized) - return - } - - file, err := os.Open("./fixtures/list_zones.json") - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder("secretA"). + Route("GET /v1/zones", servermock.ResponseFromFixture("list_zones.json")). + Build(t) zones, err := client.ListZones(t.Context()) require.NoError(t, err) @@ -88,33 +60,11 @@ func TestClient_ListZones(t *testing.T) { } func TestClient_CreateRecord(t *testing.T) { - client, mux := setupTest(t, "secretB") - - mux.HandleFunc("/v1/zones/1/records", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("invalid method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - auth := req.Header.Get("Authorization") - if auth != "Basic bWU6c2VjcmV0Qg==" { - http.Error(rw, fmt.Sprintf("invalid authentication: %s", auth), http.StatusUnauthorized) - return - } - - file, err := os.Open("./fixtures/create_record.json") - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder("secretB"). + Route("POST /v1/zones/1/records", + servermock.ResponseFromFixture("create_record.json"), + servermock.CheckRequestJSONBody(`{"name":"example.com.","type":"MX","content":"10 mail.example.com.","ttl":300}`)). + Build(t) zone := DNSZone{ID: 1} @@ -141,33 +91,11 @@ func TestClient_CreateRecord(t *testing.T) { } func TestClient_DeleteRecord(t *testing.T) { - client, mux := setupTest(t, "secretC") - - mux.HandleFunc("/v1/zones/1/records/2", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodDelete { - http.Error(rw, fmt.Sprintf("invalid method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - auth := req.Header.Get("Authorization") - if auth != "Basic bWU6c2VjcmV0Qw==" { - http.Error(rw, fmt.Sprintf("invalid authentication: %s", auth), http.StatusUnauthorized) - return - } - - file, err := os.Open("./fixtures/delete_record.json") - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder("secretC"). + Route("DELETE /v1/zones/1/records/2", + servermock.ResponseFromFixture("delete_record.json"), + servermock.CheckRequestJSONBody(`{"id":2,"name":"example.com.","type":"MX","content":"10 mail.example.com.","ttl":300,"zone_id":1}`)). + Build(t) record := &DNSRecord{ ID: 2, diff --git a/providers/dns/manageengine/internal/client_test.go b/providers/dns/manageengine/internal/client_test.go index a47d0b9a8..0c18a245f 100644 --- a/providers/dns/manageengine/internal/client_test.go +++ b/providers/dns/manageengine/internal/client_test.go @@ -1,57 +1,35 @@ package internal import ( - "io" + "context" "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, pattern string, status int, filename string) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient(context.Background(), "abc", "secret") - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) + client.httpClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) - mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) { - if filename == "" { - rw.WriteHeader(status) - return - } - - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = file.Close() }() - - rw.WriteHeader(status) - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - client := NewClient(t.Context(), "abc", "secret") - - client.httpClient = server.Client() - client.baseURL, _ = url.Parse(server.URL) - - return client + return client, nil + }, + servermock.CheckHeader(). + WithAccept("application/json")) } func TestClient_GetAllZones(t *testing.T) { - client := setupTest(t, "GET /dns/domain", http.StatusOK, "zone_domains_all.json") + client := mockBuilder(). + Route("GET /dns/domain", servermock.ResponseFromFixture("zone_domains_all.json")). + Build(t) groups, err := client.GetAllZones(t.Context()) require.NoError(t, err) @@ -132,7 +110,11 @@ func TestClient_GetAllZones(t *testing.T) { } func TestClient_GetAllZones_error(t *testing.T) { - client := setupTest(t, "GET /dns/domain", http.StatusUnauthorized, "error.json") + client := mockBuilder(). + Route("GET /dns/domain", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) _, err := client.GetAllZones(t.Context()) require.Error(t, err) @@ -141,7 +123,9 @@ func TestClient_GetAllZones_error(t *testing.T) { } func TestClient_GetAllZoneRecords(t *testing.T) { - client := setupTest(t, "GET /dns/domain/4/records/SPF_TXT", http.StatusOK, "zone_records_all.json") + client := mockBuilder(). + Route("GET /dns/domain/4/records/SPF_TXT", servermock.ResponseFromFixture("zone_records_all.json")). + Build(t) groups, err := client.GetAllZoneRecords(t.Context(), 4) require.NoError(t, err) @@ -179,7 +163,11 @@ func TestClient_GetAllZoneRecords(t *testing.T) { } func TestClient_GetAllZoneRecords_error(t *testing.T) { - client := setupTest(t, "GET /dns/domain/4/records/SPF_TXT", http.StatusUnauthorized, "error.json") + client := mockBuilder(). + Route("GET /dns/domain/4/records/SPF_TXT", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) _, err := client.GetAllZoneRecords(t.Context(), 4) require.Error(t, err) @@ -188,14 +176,20 @@ func TestClient_GetAllZoneRecords_error(t *testing.T) { } func TestClient_DeleteZoneRecord(t *testing.T) { - client := setupTest(t, "DELETE /dns/domain/4/records/SPF_TXT/6", http.StatusOK, "zone_record_delete.json") + client := mockBuilder(). + Route("DELETE /dns/domain/4/records/SPF_TXT/6", servermock.ResponseFromFixture("zone_record_delete.json")). + Build(t) err := client.DeleteZoneRecord(t.Context(), 4, 6) require.NoError(t, err) } func TestClient_DeleteZoneRecord_error(t *testing.T) { - client := setupTest(t, "DELETE /dns/domain/4/records/SPF_TXT/6", http.StatusUnauthorized, "error.json") + client := mockBuilder(). + Route("DELETE /dns/domain/4/records/SPF_TXT/6", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) err := client.DeleteZoneRecord(t.Context(), 4, 6) require.Error(t, err) @@ -204,16 +198,45 @@ func TestClient_DeleteZoneRecord_error(t *testing.T) { } func TestClient_CreateZoneRecord(t *testing.T) { - client := setupTest(t, "POST /dns/domain/4/records/SPF_TXT/", http.StatusOK, "zone_record_create.json") + client := mockBuilder(). + Route("POST /dns/domain/4/records/SPF_TXT/", + servermock.ResponseFromFixture("zone_record_create.json"), + servermock.CheckHeader(). + WithContentTypeFromURLEncoded(), + servermock.CheckForm().Strict(). + With("config", `[{"zone_id":1,"spf_txt_domain_id":2,"domain_name":"example.com","domain_ttl":120,"domain_location_id":3,"record_type":"TXT","records":[{"record_id":123,"value":["value1"],"domain_id":1}]}] +`)). + Build(t) - record := ZoneRecord{} + record := ZoneRecord{ + ZoneID: 1, + SpfTxtDomainID: 2, + DomainName: "example.com", + DomainTTL: 120, + DomainLocationID: 3, + RecordType: "TXT", + Records: []Record{ + { + ID: 123, + Values: []string{"value1"}, + Disabled: false, + DomainID: 1, + }, + }, + } err := client.CreateZoneRecord(t.Context(), 4, record) require.NoError(t, err) } func TestClient_CreateZoneRecord_error(t *testing.T) { - client := setupTest(t, "POST /dns/domain/4/records/SPF_TXT/", http.StatusUnauthorized, "error.json") + client := mockBuilder(). + Route("POST /dns/domain/4/records/SPF_TXT/", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized), + servermock.CheckHeader(). + WithContentTypeFromURLEncoded()). + Build(t) record := ZoneRecord{} @@ -224,7 +247,13 @@ func TestClient_CreateZoneRecord_error(t *testing.T) { } func TestClient_CreateZoneRecord_error_bad_request(t *testing.T) { - client := setupTest(t, "POST /dns/domain/4/records/SPF_TXT/", http.StatusBadRequest, "error_bad_request.json") + client := mockBuilder(). + Route("POST /dns/domain/4/records/SPF_TXT/", + servermock.ResponseFromFixture("error_bad_request.json"). + WithStatusCode(http.StatusBadRequest), + servermock.CheckHeader(). + WithContentTypeFromURLEncoded()). + Build(t) record := ZoneRecord{} @@ -235,7 +264,15 @@ func TestClient_CreateZoneRecord_error_bad_request(t *testing.T) { } func TestClient_UpdateZoneRecord(t *testing.T) { - client := setupTest(t, "PUT /dns/domain/4/records/SPF_TXT/6/", http.StatusOK, "zone_record_update.json") + client := mockBuilder(). + Route("PUT /dns/domain/4/records/SPF_TXT/6/", + servermock.ResponseFromFixture("zone_record_update.json"), + servermock.CheckHeader(). + WithContentTypeFromURLEncoded(), + servermock.CheckForm().Strict(). + With("config", `[{"zone_id":4,"spf_txt_domain_id":6,"records":null}] +`)). + Build(t) record := ZoneRecord{ SpfTxtDomainID: 6, @@ -247,7 +284,13 @@ func TestClient_UpdateZoneRecord(t *testing.T) { } func TestClient_UpdateZoneRecord_error(t *testing.T) { - client := setupTest(t, "PUT /dns/domain/4/records/SPF_TXT/6/", http.StatusUnauthorized, "error.json") + client := mockBuilder(). + Route("PUT /dns/domain/4/records/SPF_TXT/6/", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized), + servermock.CheckHeader(). + WithContentTypeFromURLEncoded()). + Build(t) record := ZoneRecord{ SpfTxtDomainID: 6, diff --git a/providers/dns/metaregistrar/internal/client.go b/providers/dns/metaregistrar/internal/client.go index f2838c532..4d3c8adc4 100644 --- a/providers/dns/metaregistrar/internal/client.go +++ b/providers/dns/metaregistrar/internal/client.go @@ -16,6 +16,8 @@ import ( const defaultBaseURL = "https://api.metaregistrar.com" +const tokenHeader = "token" + // Client is a client to interact with the Metaregistrar API. type Client struct { token string @@ -61,7 +63,7 @@ func (c Client) UpdateDNSZone(ctx context.Context, domain string, updateRequest } func (c Client) do(req *http.Request, result any) error { - req.Header.Add("token", c.token) + req.Header.Add(tokenHeader, c.token) resp, err := c.HTTPClient.Do(req) if err != nil { diff --git a/providers/dns/metaregistrar/internal/client_test.go b/providers/dns/metaregistrar/internal/client_test.go index 8486fc899..33e92cd7b 100644 --- a/providers/dns/metaregistrar/internal/client_test.go +++ b/providers/dns/metaregistrar/internal/client_test.go @@ -1,58 +1,39 @@ package internal import ( - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, pattern string, status int, filename string) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient("secret") + if err != nil { + return nil, err + } - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) - mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) { - if filename == "" { - rw.WriteHeader(status) - return - } - - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = file.Close() }() - - rw.WriteHeader(status) - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - client, err := NewClient("token") - require.NoError(t, err) - - client.HTTPClient = server.Client() - client.baseURL, _ = url.Parse(server.URL) - - return client + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + With(tokenHeader, "secret")) } func TestClient_UpdateDNSZone(t *testing.T) { - client := setupTest(t, "PATCH /dnszone/example.com", http.StatusOK, "update-dns-zone.json") + client := mockBuilder(). + Route("PATCH /dnszone/example.com", + servermock.ResponseFromFixture("update-dns-zone.json"), + servermock.CheckRequestJSONBody(`{"add":[{"name":"@","type":"TXT","ttl":60,"content":"value"}]}`)). + Build(t) updateRequest := DNSZoneUpdateRequest{ Add: []Record{{ @@ -95,7 +76,11 @@ func TestClient_UpdateDNSZone_error(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - client := setupTest(t, "PATCH /dnszone/example.com", http.StatusUnprocessableEntity, test.filename) + client := mockBuilder(). + Route("PATCH /dnszone/example.com", + servermock.ResponseFromFixture(test.filename). + WithStatusCode(http.StatusUnprocessableEntity)). + Build(t) updateRequest := DNSZoneUpdateRequest{ Add: []Record{{ diff --git a/providers/dns/mijnhost/internal/client_test.go b/providers/dns/mijnhost/internal/client_test.go index a1dc326b7..208616541 100644 --- a/providers/dns/mijnhost/internal/client_test.go +++ b/providers/dns/mijnhost/internal/client_test.go @@ -1,69 +1,35 @@ package internal import ( - "fmt" - "io" - "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const apiKey = "secret" -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient(apiKey) + client.baseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := NewClient(apiKey) - client.baseURL, _ = url.Parse(server.URL) - - return client, mux -} - -func testHandler(filename, method string, statusCode int) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - auth := req.Header.Get(authorizationHeader) - if auth != apiKey { - http.Error(rw, "invalid Authorization header", http.StatusUnauthorized) - return - } - - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = file.Close() }() - - rw.WriteHeader(statusCode) - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - } + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + With(authorizationHeader, apiKey), + ) } func TestClient_ListDomains(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/domains", testHandler("./list-domains.json", http.MethodGet, http.StatusOK)) + client := mockBuilder(). + Route("GET /domains", servermock.ResponseFromFixture("list-domains.json")). + Build(t) domains, err := client.ListDomains(t.Context()) require.NoError(t, err) @@ -81,9 +47,9 @@ func TestClient_ListDomains(t *testing.T) { } func TestClient_GetRecords(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/domains/example.com/dns", testHandler("./get-dns-records.json", http.MethodGet, http.StatusOK)) + client := mockBuilder(). + Route("GET /domains/example.com/dns", servermock.ResponseFromFixture("get-dns-records.json")). + Build(t) records, err := client.GetRecords(t.Context(), "example.com") require.NoError(t, err) @@ -119,10 +85,19 @@ func TestClient_GetRecords(t *testing.T) { } func TestClient_UpdateRecords(t *testing.T) { - client, mux := setupTest(t) + client := mockBuilder(). + Route("PUT /domains/example.com/dns", + servermock.ResponseFromFixture("update-dns-records.json"), + servermock.CheckRequestJSONBody(`{"records":[{"type":"TXT","name":"foo","value":"value1","ttl":120}]}`)). + Build(t) - mux.HandleFunc("/domains/example.com/dns", testHandler("./update-dns-records.json", http.MethodPut, http.StatusOK)) + records := []Record{{ + Type: "TXT", + Name: "foo", + Value: "value1", + TTL: 120, + }} - err := client.UpdateRecords(t.Context(), "example.com", nil) + err := client.UpdateRecords(t.Context(), "example.com", records) require.NoError(t, err) } diff --git a/providers/dns/mittwald/internal/client_test.go b/providers/dns/mittwald/internal/client_test.go index f73a36cc1..e57c80f7a 100644 --- a/providers/dns/mittwald/internal/client_test.go +++ b/providers/dns/mittwald/internal/client_test.go @@ -1,72 +1,34 @@ package internal import ( - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, pattern string, handler http.HandlerFunc) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("secret") + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc(pattern, handler) - - client := NewClient("secret") - client.HTTPClient = server.Client() - client.baseURL, _ = url.Parse(server.URL) - - return client -} - -func testHandler(method string, statusCode int, filename string) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf(`{"message":"unsupported method: %s"}`, req.Method), http.StatusMethodNotAllowed) - return - } - - auth := req.Header.Get(authorizationHeader) - if auth != "Bearer secret" { - http.Error(rw, fmt.Sprintf("invalid API Token: %s", auth), http.StatusUnauthorized) - return - } - - rw.WriteHeader(statusCode) - - if statusCode == http.StatusNoContent { - return - } - - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, fmt.Sprintf(`{"message":"%v"}`, err), http.StatusInternalServerError) - return - } - - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, fmt.Sprintf(`{"message":"%v"}`, err), http.StatusInternalServerError) - return - } - } + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("Bearer secret"), + ) } func TestClient_ListDomains(t *testing.T) { - client := setupTest(t, "/domains", testHandler(http.MethodGet, http.StatusOK, "domain-list-domains.json")) + client := mockBuilder(). + Route("GET /domains", servermock.ResponseFromFixture("domain-list-domains.json")). + Build(t) domains, err := client.ListDomains(t.Context()) require.NoError(t, err) @@ -83,14 +45,20 @@ func TestClient_ListDomains(t *testing.T) { } func TestClient_ListDomains_error(t *testing.T) { - client := setupTest(t, "/domains", testHandler(http.MethodGet, http.StatusBadRequest, "error-client.json")) + client := mockBuilder(). + Route("GET /domains", + servermock.ResponseFromFixture("error-client.json"). + WithStatusCode(http.StatusBadRequest)). + Build(t) _, err := client.ListDomains(t.Context()) require.EqualError(t, err, "[status code 400] ValidationError: Validation failed [format: should be string (.address.street, email)]") } func TestClient_ListDNSZones(t *testing.T) { - client := setupTest(t, "/projects/my-project-id/dns-zones", testHandler(http.MethodGet, http.StatusOK, "dns-list-dns-zones.json")) + client := mockBuilder(). + Route("GET /projects/my-project-id/dns-zones", servermock.ResponseFromFixture("dns-list-dns-zones.json")). + Build(t) zones, err := client.ListDNSZones(t.Context(), "my-project-id") require.NoError(t, err) @@ -109,7 +77,9 @@ func TestClient_ListDNSZones(t *testing.T) { } func TestClient_GetDNSZone(t *testing.T) { - client := setupTest(t, "/dns-zones/my-zone-id", testHandler(http.MethodGet, http.StatusOK, "dns-get-dns-zone.json")) + client := mockBuilder(). + Route("GET /dns-zones/my-zone-id", servermock.ResponseFromFixture("dns-get-dns-zone.json")). + Build(t) zone, err := client.GetDNSZone(t.Context(), "my-zone-id") require.NoError(t, err) @@ -126,7 +96,11 @@ func TestClient_GetDNSZone(t *testing.T) { } func TestClient_CreateDNSZone(t *testing.T) { - client := setupTest(t, "/dns-zones", testHandler(http.MethodPost, http.StatusCreated, "dns-create-dns-zone.json")) + client := mockBuilder(). + Route("POST /dns-zones", + servermock.ResponseFromFixture("dns-create-dns-zone.json"), + servermock.CheckRequestJSONBody(`{"name":"test","parentZoneId":"my-parent-zone-id"}`)). + Build(t) request := CreateDNSZoneRequest{ Name: "test", @@ -144,7 +118,12 @@ func TestClient_CreateDNSZone(t *testing.T) { } func TestClient_UpdateTXTRecord(t *testing.T) { - client := setupTest(t, "/dns-zones/my-zone-id/record-sets/txt", testHandler(http.MethodPut, http.StatusNoContent, "")) + client := mockBuilder(). + Route("PUT /dns-zones/my-zone-id/record-sets/txt", + servermock.Noop(). + WithStatusCode(http.StatusNoContent), + servermock.CheckRequestJSONBody(`{"settings":{"ttl":{"auto":true}},"entries":["txt"]}`)). + Build(t) record := TXTRecord{ Settings: Settings{ @@ -158,14 +137,21 @@ func TestClient_UpdateTXTRecord(t *testing.T) { } func TestClient_DeleteDNSZone(t *testing.T) { - client := setupTest(t, "/dns-zones/my-zone-id", testHandler(http.MethodDelete, http.StatusOK, "")) + client := mockBuilder(). + Route("DELETE /dns-zones/my-zone-id", + servermock.Noop()). + Build(t) err := client.DeleteDNSZone(t.Context(), "my-zone-id") require.NoError(t, err) } func TestClient_DeleteDNSZone_error(t *testing.T) { - client := setupTest(t, "/dns-zones/my-zone-id", testHandler(http.MethodDelete, http.StatusInternalServerError, "error.json")) + client := mockBuilder(). + Route("DELETE /dns-zones/my-zone-id", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusInternalServerError)). + Build(t) err := client.DeleteDNSZone(t.Context(), "my-zone-id") assert.EqualError(t, err, "[status code 500] InternalServerError: Something went wrong") diff --git a/providers/dns/myaddr/internal/client_test.go b/providers/dns/myaddr/internal/client_test.go index 794a501fb..36506d94a 100644 --- a/providers/dns/myaddr/internal/client_test.go +++ b/providers/dns/myaddr/internal/client_test.go @@ -1,75 +1,61 @@ package internal import ( - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, pattern string, status int, filename string) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + credentials := map[string]string{ + "example": "secret", + } - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) + client, err := NewClient(credentials) + if err != nil { + return nil, err + } - mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) { - if filename == "" { - rw.WriteHeader(status) - return - } + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = file.Close() }() - - rw.WriteHeader(status) - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - credentials := map[string]string{ - "example": "secret", - } - - client, err := NewClient(credentials) - require.NoError(t, err) - - client.HTTPClient = server.Client() - client.baseURL, _ = url.Parse(server.URL) - - return client + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(), + ) } func TestClient_AddTXTRecord(t *testing.T) { - client := setupTest(t, "POST /update", http.StatusOK, "") + client := mockBuilder(). + Route("POST /update", nil, + servermock.CheckRequestJSONBody(`{"key":"secret","acme_challenge":"txt"}`)). + Build(t) err := client.AddTXTRecord(t.Context(), "example", "txt") require.NoError(t, err) } func TestClient_AddTXTRecord_error(t *testing.T) { - client := setupTest(t, "POST /update", http.StatusBadRequest, "error.txt") + client := mockBuilder(). + Route("POST /update", + servermock.ResponseFromFixture("error.txt"). + WithStatusCode(http.StatusBadRequest)). + Build(t) err := client.AddTXTRecord(t.Context(), "example", "txt") require.EqualError(t, err, `unexpected status code: [status code: 400] body: invalid value for "key"`) } func TestClient_AddTXTRecord_error_credentials(t *testing.T) { - client := setupTest(t, "POST /update", http.StatusOK, "") + client := mockBuilder(). + Route("POST /update", nil). + Build(t) err := client.AddTXTRecord(t.Context(), "nx", "txt") require.EqualError(t, err, "subdomain nx not found in credentials, check your credentials map") diff --git a/providers/dns/mydnsjp/internal/client_test.go b/providers/dns/mydnsjp/internal/client_test.go index a0f9ab8c7..41ccbba87 100644 --- a/providers/dns/mydnsjp/internal/client_test.go +++ b/providers/dns/mydnsjp/internal/client_test.go @@ -1,90 +1,49 @@ package internal import ( - "fmt" - "net/http" "net/http/httptest" "net/url" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, cmdName string) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("xxx", "secret") + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("invalid method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - username, password, ok := req.BasicAuth() - if !ok { - http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return - } - - if username != "xxx" { - http.Error(rw, fmt.Sprintf("username: want %s got %s", username, "xxx"), http.StatusUnauthorized) - return - } - - if password != "secret" { - http.Error(rw, fmt.Sprintf("password: want %s got %s", password, "secret"), http.StatusUnauthorized) - return - } - - if req.Header.Get("Content-Type") != "application/x-www-form-urlencoded" { - http.Error(rw, fmt.Sprintf("invalid Content-Type: %s", req.Header.Get("Content-Type")), http.StatusBadRequest) - return - } - - err := req.ParseForm() - if err != nil { - http.Error(rw, err.Error(), http.StatusBadRequest) - return - } - - domain := req.Form.Get("CERTBOT_DOMAIN") - if domain != "example.com" { - http.Error(rw, fmt.Sprintf("unexpected CERTBOT_DOMAIN: %s", domain), http.StatusBadRequest) - return - } - - validation := req.Form.Get("CERTBOT_VALIDATION") - if validation != "txt" { - http.Error(rw, fmt.Sprintf("unexpected CERTBOT_VALIDATION: %s", validation), http.StatusBadRequest) - return - } - - cmd := req.Form.Get("EDIT_CMD") - if cmd != cmdName { - http.Error(rw, fmt.Sprintf("unexpected EDIT_CMD: %s", cmd), http.StatusBadRequest) - return - } - }) - - client := NewClient("xxx", "secret") - client.HTTPClient = server.Client() - client.baseURL, _ = url.Parse(server.URL) - - return client + return client, nil + }, + servermock.CheckHeader(). + WithContentTypeFromURLEncoded(). + WithBasicAuth("xxx", "secret")) } func TestClient_AddTXTRecord(t *testing.T) { - client := setupTest(t, "REGIST") + client := mockBuilder(). + Route("POST /", nil, + servermock.CheckForm().Strict(). + With("CERTBOT_DOMAIN", "example.com"). + With("CERTBOT_VALIDATION", "txt"). + With("EDIT_CMD", "REGIST")). + Build(t) err := client.AddTXTRecord(t.Context(), "example.com", "txt") require.NoError(t, err) } func TestClient_DeleteTXTRecord(t *testing.T) { - client := setupTest(t, "DELETE") + client := mockBuilder(). + Route("POST /", nil, + servermock.CheckForm().Strict(). + With("CERTBOT_DOMAIN", "example.com"). + With("CERTBOT_VALIDATION", "txt"). + With("EDIT_CMD", "DELETE")). + Build(t) err := client.DeleteTXTRecord(t.Context(), "example.com", "txt") require.NoError(t, err) diff --git a/providers/dns/mythicbeasts/internal/client_test.go b/providers/dns/mythicbeasts/internal/client_test.go index 1e5f83e3c..acbf85268 100644 --- a/providers/dns/mythicbeasts/internal/client_test.go +++ b/providers/dns/mythicbeasts/internal/client_test.go @@ -1,68 +1,53 @@ package internal import ( - "fmt" - "io" - "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" "time" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, pattern string, handler http.HandlerFunc) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("user", "secret") + client.HTTPClient = server.Client() + client.APIEndpoint, _ = url.Parse(server.URL) + client.token = &Token{ + Token: "secret", + Lifetime: 60, + TokenType: "bearer", + Deadline: time.Now().Add(1 * time.Minute), + } - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc(pattern, handler) - - client := NewClient("user", "secret") - client.HTTPClient = server.Client() - client.APIEndpoint, _ = url.Parse(server.URL) - client.token = &Token{ - Token: "secret", - Lifetime: 60, - TokenType: "bearer", - Deadline: time.Now().Add(1 * time.Minute), - } - - return client -} - -func writeFixtureHandler(method, filename string) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, _ = io.Copy(rw, file) - } + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("Bearer "+fakeToken), + ) } func TestClient_CreateTXTRecord(t *testing.T) { - client := setupTest(t, "/zones/example.com/records/foo/TXT", writeFixtureHandler(http.MethodPost, "post-zoneszonerecords.json")) + client := mockBuilder(). + Route("POST /zones/example.com/records/foo/TXT", + servermock.ResponseFromFixture("post-zoneszonerecords.json"), + servermock.CheckRequestJSONBody(`{"records":[{"host":"foo","ttl":120,"type":"TXT","data":"txt"}]}`)). + Build(t) err := client.CreateTXTRecord(mockContext(t), "example.com", "foo", "txt", 120) require.NoError(t, err) } func TestClient_RemoveTXTRecord(t *testing.T) { - client := setupTest(t, "/zones/example.com/records/foo/TXT", writeFixtureHandler(http.MethodDelete, "delete-zoneszonerecords.json")) + client := mockBuilder(). + Route("DELETE /zones/example.com/records/foo/TXT", + servermock.ResponseFromFixture("delete-zoneszonerecords.json"), + servermock.CheckQueryParameter().Strict(). + With("data", "txt")). + Build(t) err := client.RemoveTXTRecord(mockContext(t), "example.com", "foo", "txt") require.NoError(t, err) diff --git a/providers/dns/mythicbeasts/internal/fixtures/token.json b/providers/dns/mythicbeasts/internal/fixtures/token.json new file mode 100644 index 000000000..f23fe58ea --- /dev/null +++ b/providers/dns/mythicbeasts/internal/fixtures/token.json @@ -0,0 +1,5 @@ +{ + "access_token": "xxx", + "expires_in": 666, + "token_type": "bearer" +} diff --git a/providers/dns/mythicbeasts/internal/identity_test.go b/providers/dns/mythicbeasts/internal/identity_test.go index e26bad6aa..3e1e8ba4f 100644 --- a/providers/dns/mythicbeasts/internal/identity_test.go +++ b/providers/dns/mythicbeasts/internal/identity_test.go @@ -2,52 +2,45 @@ package internal import ( "context" - "encoding/json" - "fmt" - "net/http" "net/http/httptest" "net/url" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +const fakeToken = "xxx" + func mockContext(t *testing.T) context.Context { t.Helper() - return context.WithValue(t.Context(), tokenKey, &Token{Token: "xxx"}) + return context.WithValue(t.Context(), tokenKey, &Token{Token: fakeToken}) } -func tokenHandler(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("invalid method, got %s want %s", req.Method, http.MethodPost), http.StatusMethodNotAllowed) - return - } +func mockBuilderIdentity() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("user", "secret") + client.HTTPClient = server.Client() + client.AuthEndpoint, _ = url.Parse(server.URL) - username, password, ok := req.BasicAuth() - if !ok || username != "user" || password != "secret" { - http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return - } - - _ = json.NewEncoder(rw).Encode(Token{ - Token: "xxx", - Lifetime: 666, - TokenType: "bearer", - }) + return client, nil + }, + servermock.CheckHeader(). + WithBasicAuth("user", "secret"), + servermock.CheckHeader(). + WithContentTypeFromURLEncoded()) } func TestClient_obtainToken(t *testing.T) { - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc("/", tokenHandler) - - client := NewClient("user", "secret") - client.HTTPClient = server.Client() - client.AuthEndpoint, _ = url.Parse(server.URL) + client := mockBuilderIdentity(). + Route("POST /", + servermock.ResponseFromFixture("token.json"), + servermock.CheckForm().Strict(). + With("grant_type", "client_credentials")). + Build(t) assert.Nil(t, client.token) @@ -56,19 +49,16 @@ func TestClient_obtainToken(t *testing.T) { assert.NotNil(t, tok) assert.NotZero(t, tok.Deadline) - assert.Equal(t, "xxx", tok.Token) + assert.Equal(t, fakeToken, tok.Token) } func TestClient_CreateAuthenticatedContext(t *testing.T) { - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc("/", tokenHandler) - - client := NewClient("user", "secret") - client.HTTPClient = server.Client() - client.AuthEndpoint, _ = url.Parse(server.URL) + client := mockBuilderIdentity(). + Route("POST /", + servermock.ResponseFromFixture("token.json"), + servermock.CheckForm().Strict(). + With("grant_type", "client_credentials")). + Build(t) assert.Nil(t, client.token) @@ -79,5 +69,5 @@ func TestClient_CreateAuthenticatedContext(t *testing.T) { assert.NotNil(t, tok) assert.NotZero(t, tok.Deadline) - assert.Equal(t, "xxx", tok.Token) + assert.Equal(t, fakeToken, tok.Token) } diff --git a/providers/dns/namecheap/internal/client_test.go b/providers/dns/namecheap/internal/client_test.go index 6a6ba201a..d7bea7b6e 100644 --- a/providers/dns/namecheap/internal/client_test.go +++ b/providers/dns/namecheap/internal/client_test.go @@ -1,72 +1,36 @@ package internal import ( - "fmt" - "io" - "net/http" "net/http/httptest" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, handler http.HandlerFunc) *Client { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc("/", handler) - +func setupClient(server *httptest.Server) (*Client, error) { client := NewClient("user", "secret", "127.0.0.1") client.HTTPClient = server.Client() client.BaseURL = server.URL - return client -} - -func writeFixture(rw http.ResponseWriter, filename string) { - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, _ = io.Copy(rw, file) + return client, nil } func TestClient_GetHosts(t *testing.T) { - client := setupTest(t, func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - expectedParams := map[string]string{ - "ApiKey": "secret", - "ApiUser": "user", - "ClientIp": "127.0.0.1", - "Command": "namecheap.domains.dns.getHosts", - "SLD": "foo", - "TLD": "example.com", - "UserName": "user", - } - - query := req.URL.Query() - for k, v := range expectedParams { - if query.Get(k) != v { - http.Error(rw, fmt.Sprintf("invalid query parameter %s value: %s", k, query.Get(k)), http.StatusBadRequest) - return - } - } - - writeFixture(rw, "getHosts.xml") - }) + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /", + servermock.ResponseFromFixture("getHosts.xml"), + servermock.CheckQueryParameter().Strict(). + With("ApiKey", "secret"). + With("ApiUser", "user"). + With("ClientIp", "127.0.0.1"). + With("Command", "namecheap.domains.dns.getHosts"). + With("SLD", "foo"). + With("TLD", "example.com"). + With("UserName", "user"), + ). + Build(t) hosts, err := client.GetHosts(t.Context(), "foo", "example.com") require.NoError(t, err) @@ -80,68 +44,41 @@ func TestClient_GetHosts(t *testing.T) { } func TestClient_GetHosts_error(t *testing.T) { - client := setupTest(t, func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - writeFixture(rw, "getHosts_errorBadAPIKey1.xml") - }) + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /", + servermock.ResponseFromFixture("getHosts_errorBadAPIKey1.xml")). + Build(t) _, err := client.GetHosts(t.Context(), "foo", "example.com") require.ErrorAs(t, err, &apiError{}) } func TestClient_SetHosts(t *testing.T) { - client := setupTest(t, func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - if req.Header.Get("Content-Type") != "application/x-www-form-urlencoded" { - http.Error(rw, fmt.Sprintf("invalid Content-Type: %s", req.Header.Get("Content-Type")), http.StatusBadRequest) - return - } - - err := req.ParseForm() - if err != nil { - http.Error(rw, err.Error(), http.StatusBadRequest) - return - } - - expectedParams := map[string]string{ - "HostName1": "_acme-challenge.test.example.com", - "RecordType1": "TXT", - "Address1": "txtTXTtxt", - "MXPref1": "10", - "TTL1": "120", - - "HostName2": "_acme-challenge.test.example.org", - "RecordType2": "TXT", - "Address2": "txtTXTtxt", - "MXPref2": "10", - "TTL2": "120", - - "ApiKey": "secret", - "ApiUser": "user", - "ClientIp": "127.0.0.1", - "Command": "namecheap.domains.dns.setHosts", - "SLD": "foo", - "TLD": "example.com", - "UserName": "user", - } - - for k, v := range expectedParams { - if req.Form.Get(k) != v { - http.Error(rw, fmt.Sprintf("invalid form data %s value: %q", k, req.Form.Get(k)), http.StatusBadRequest) - return - } - } - - writeFixture(rw, "setHosts.xml") - }) + client := servermock.NewBuilder[*Client](setupClient, servermock.CheckHeader().WithContentTypeFromURLEncoded()). + Route("POST /", + servermock.ResponseFromFixture("setHosts.xml"), + servermock.CheckForm().Strict(). + With("ApiKey", "secret"). + With("ApiUser", "user"). + With("ClientIp", "127.0.0.1"). + With("Command", "namecheap.domains.dns.setHosts"). + With("SLD", "foo"). + With("TLD", "example.com"). + With("UserName", "user"). + // entry 1 + With("HostName1", "_acme-challenge.test.example.com"). + With("RecordType1", "TXT"). + With("Address1", "txtTXTtxt"). + With("MXPref1", "10"). + With("TTL1", "120"). + // entry 2 + With("HostName2", "_acme-challenge.test.example.org"). + With("RecordType2", "TXT"). + With("Address2", "txtTXTtxt"). + With("MXPref2", "10"). + With("TTL2", "120"), + ). + Build(t) records := []Record{ {Name: "_acme-challenge.test.example.com", Type: "TXT", Address: "txtTXTtxt", MXPref: "10", TTL: "120"}, @@ -153,14 +90,10 @@ func TestClient_SetHosts(t *testing.T) { } func TestClient_SetHosts_error(t *testing.T) { - client := setupTest(t, func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - writeFixture(rw, "setHosts_errorBadAPIKey1.xml") - }) + client := servermock.NewBuilder[*Client](setupClient). + Route("POST /", + servermock.ResponseFromFixture("setHosts_errorBadAPIKey1.xml")). + Build(t) records := []Record{ {Name: "_acme-challenge.test.example.com", Type: "TXT", Address: "txtTXTtxt", MXPref: "10", TTL: "120"}, diff --git a/providers/dns/namecheap/namecheap_test.go b/providers/dns/namecheap/namecheap_test.go index 01f87aaf0..fedbc3162 100644 --- a/providers/dns/namecheap/namecheap_test.go +++ b/providers/dns/namecheap/namecheap_test.go @@ -1,16 +1,13 @@ package namecheap import ( - "io" "net/http" "net/http/httptest" - "net/url" - "os" "path/filepath" "testing" "time" - "github.com/go-acme/lego/v4/providers/dns/namecheap/internal" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -24,7 +21,6 @@ const ( type testCase struct { name string domain string - hosts []internal.Record errString string getHostsResponse string setHostsResponse string @@ -32,26 +28,14 @@ type testCase struct { var testCases = []testCase{ { - name: "Test:Success:1", - domain: "test.example.com", - hosts: []internal.Record{ - {Type: "A", Name: "home", Address: "10.0.0.1", MXPref: "10", TTL: "1799"}, - {Type: "A", Name: "www", Address: "10.0.0.2", MXPref: "10", TTL: "1200"}, - {Type: "AAAA", Name: "a", Address: "::0", MXPref: "10", TTL: "1799"}, - {Type: "CNAME", Name: "*", Address: "example.com.", MXPref: "10", TTL: "1799"}, - {Type: "MXE", Name: "example.com", Address: "10.0.0.5", MXPref: "10", TTL: "1800"}, - {Type: "URL", Name: "xyz", Address: "https://google.com", MXPref: "10", TTL: "1799"}, - }, + name: "Test:Success:1", + domain: "test.example.com", getHostsResponse: "getHosts_success1.xml", setHostsResponse: "setHosts_success1.xml", }, { - name: "Test:Success:2", - domain: "example.com", - hosts: []internal.Record{ - {Type: "A", Name: "@", Address: "10.0.0.2", MXPref: "10", TTL: "1200"}, - {Type: "A", Name: "www", Address: "10.0.0.3", MXPref: "10", TTL: "60"}, - }, + name: "Test:Success:2", + domain: "example.com", getHostsResponse: "getHosts_success2.xml", setHostsResponse: "setHosts_success2.xml", }, @@ -63,96 +47,37 @@ var testCases = []testCase{ }, } -func setupTest(t *testing.T, tc *testCase) *DNSProvider { - t.Helper() - - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - values := r.URL.Query() - cmd := values.Get("Command") - switch cmd { - case "namecheap.domains.dns.getHosts": - assertHdr(t, tc, &values) - w.WriteHeader(http.StatusOK) - writeFixture(w, tc.getHostsResponse) - default: - t.Errorf("Unexpected GET command: %s", cmd) - } - - case http.MethodPost: - err := r.ParseForm() - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - values := r.Form - cmd := values.Get("Command") - switch cmd { - case "namecheap.domains.dns.setHosts": - assertHdr(t, tc, &values) - w.WriteHeader(http.StatusOK) - writeFixture(w, tc.setHostsResponse) - default: - t.Errorf("Unexpected POST command: %s", cmd) - } - - default: - t.Errorf("Unexpected http method: %s", r.Method) - } - }) - - server := httptest.NewServer(handler) - t.Cleanup(server.Close) - - return mockDNSProvider(t, server.URL) -} - -func mockDNSProvider(t *testing.T, baseURL string) *DNSProvider { - t.Helper() - - config := NewDefaultConfig() - config.BaseURL = baseURL - config.APIUser = envTestUser - config.APIKey = envTestKey - config.ClientIP = envTestClientIP - config.HTTPClient = &http.Client{Timeout: 60 * time.Second} - - provider, err := NewDNSProviderConfig(config) - require.NoError(t, err) - - return provider -} - -func assertHdr(t *testing.T, tc *testCase, values *url.Values) { - t.Helper() - - ch, _ := newPseudoRecord(tc.domain, "") - assert.Equal(t, envTestUser, values.Get("ApiUser"), "ApiUser") - assert.Equal(t, envTestKey, values.Get("ApiKey"), "ApiKey") - assert.Equal(t, envTestUser, values.Get("UserName"), "UserName") - assert.Equal(t, envTestClientIP, values.Get("ClientIp"), "ClientIp") - assert.Equal(t, ch.sld, values.Get("SLD"), "SLD") - assert.Equal(t, ch.tld, values.Get("TLD"), "TLD") -} - -func writeFixture(rw http.ResponseWriter, filename string) { - file, err := os.Open(filepath.Join("internal", "fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, _ = io.Copy(rw, file) -} - func TestDNSProvider_Present(t *testing.T) { for _, test := range testCases { t.Run(test.name, func(t *testing.T) { - p := setupTest(t, &test) + ch, _ := newPseudoRecord(test.domain, "") - err := p.Present(test.domain, "", "dummyKey") + provider := mockBuilder(). + Route("GET /", + servermock.ResponseFromFile(filepath.Join("internal", "fixtures", test.getHostsResponse)), + servermock.CheckForm().Strict(). + With("ClientIp", "10.0.0.1"). + With("Command", "namecheap.domains.dns.getHosts"). + With("SLD", ch.sld). + With("TLD", ch.tld). + With("UserName", "foo"). + With("ApiKey", "bar"). + With("ApiUser", "foo"), + ). + Route("POST /", + servermock.ResponseFromFile(filepath.Join("internal", "fixtures", test.setHostsResponse)), + servermock.CheckForm(). + With("ClientIp", "10.0.0.1"). + With("Command", "namecheap.domains.dns.setHosts"). + With("SLD", ch.sld). + With("TLD", ch.tld). + With("UserName", "foo"). + With("ApiKey", "bar"). + With("ApiUser", "foo"), + ). + Build(t) + + err := provider.Present(test.domain, "", "dummyKey") if test.errString != "" { assert.EqualError(t, err, "namecheap: "+test.errString) } else { @@ -165,9 +90,34 @@ func TestDNSProvider_Present(t *testing.T) { func TestDNSProvider_CleanUp(t *testing.T) { for _, test := range testCases { t.Run(test.name, func(t *testing.T) { - p := setupTest(t, &test) + ch, _ := newPseudoRecord(test.domain, "") - err := p.CleanUp(test.domain, "", "dummyKey") + provider := mockBuilder(). + Route("GET /", + servermock.ResponseFromFile(filepath.Join("internal", "fixtures", test.getHostsResponse)), + servermock.CheckForm().Strict(). + With("ClientIp", "10.0.0.1"). + With("Command", "namecheap.domains.dns.getHosts"). + With("SLD", ch.sld). + With("TLD", ch.tld). + With("UserName", "foo"). + With("ApiKey", "bar"). + With("ApiUser", "foo"), + ). + Route("POST /", + servermock.ResponseFromFile(filepath.Join("internal", "fixtures", test.setHostsResponse)), + servermock.CheckForm(). + With("ClientIp", "10.0.0.1"). + With("Command", "namecheap.domains.dns.setHosts"). + With("SLD", ch.sld). + With("TLD", ch.tld). + With("UserName", "foo"). + With("ApiKey", "bar"). + With("ApiUser", "foo"), + ). + Build(t) + + err := provider.CleanUp(test.domain, "", "dummyKey") if test.errString != "" { assert.EqualError(t, err, "namecheap: "+test.errString) } else { @@ -226,3 +176,16 @@ func Test_newPseudoRecord_domainSplit(t *testing.T) { }) } } + +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder(func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.BaseURL = server.URL + config.APIUser = envTestUser + config.APIKey = envTestKey + config.ClientIP = envTestClientIP + config.HTTPClient = &http.Client{Timeout: 60 * time.Second} + + return NewDNSProviderConfig(config) + }) +} diff --git a/providers/dns/nearlyfreespeech/internal/client_test.go b/providers/dns/nearlyfreespeech/internal/client_test.go index 9c0329978..1445286c3 100644 --- a/providers/dns/nearlyfreespeech/internal/client_test.go +++ b/providers/dns/nearlyfreespeech/internal/client_test.go @@ -1,26 +1,18 @@ package internal import ( - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" "testing" "time" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - +func setupClient(server *httptest.Server) (*Client, error) { client := NewClient("user", "secret") client.HTTPClient = server.Client() client.baseURL, _ = url.Parse(server.URL) @@ -28,66 +20,22 @@ func setupTest(t *testing.T) (*Client, *http.ServeMux) { client.signer.saltShaker = func() []byte { return []byte("0123456789ABCDEF") } client.signer.clock = func() time.Time { return time.Unix(1692475113, 0) } - return client, mux -} - -func testHandler(params map[string]string) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } - - if req.Header.Get(authenticationHeader) == "" { - http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return - } - - err := req.ParseForm() - if err != nil { - http.Error(rw, err.Error(), http.StatusBadRequest) - return - } - - for k, v := range params { - if req.PostForm.Get(k) != v { - http.Error(rw, fmt.Sprintf("data: got %s want %s", k, v), http.StatusBadRequest) - return - } - } - } -} - -func testErrorHandler() http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } - - file, err := os.Open("./fixtures/error.json") - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - rw.WriteHeader(http.StatusUnauthorized) - - _, _ = io.Copy(rw, file) - } + return client, nil } func TestClient_AddRecord(t *testing.T) { - client, mux := setupTest(t) - - params := map[string]string{ - "data": "txtTXTtxt", - "name": "sub", - "type": "TXT", - "ttl": "30", - } - - mux.Handle("/dns/example.com/addRR", testHandler(params)) + client := servermock.NewBuilder[*Client](setupClient, + servermock.CheckHeader(). + WithContentTypeFromURLEncoded(). + With(authenticationHeader, "user;1692475113;0123456789ABCDEF;24a32faf74c7bd0525f560ff12a1c1fb6545bafc"), + ). + Route("POST /dns/example.com/addRR", nil, servermock.CheckForm().Strict(). + With("data", "txtTXTtxt"). + With("name", "sub"). + With("type", "TXT"). + With("ttl", "30"), + ). + Build(t) record := Record{ Name: "sub", @@ -101,9 +49,15 @@ func TestClient_AddRecord(t *testing.T) { } func TestClient_AddRecord_error(t *testing.T) { - client, mux := setupTest(t) - - mux.Handle("/dns/example.com/addRR", testErrorHandler()) + client := servermock.NewBuilder[*Client](setupClient, + servermock.CheckHeader(). + WithContentTypeFromURLEncoded(). + With(authenticationHeader, "user;1692475113;0123456789ABCDEF;24a32faf74c7bd0525f560ff12a1c1fb6545bafc"), + ). + Route("POST /dns/example.com/addRR", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) record := Record{ Name: "sub", @@ -117,15 +71,18 @@ func TestClient_AddRecord_error(t *testing.T) { } func TestClient_RemoveRecord(t *testing.T) { - client, mux := setupTest(t) - - params := map[string]string{ - "data": "txtTXTtxt", - "name": "sub", - "type": "TXT", - } - - mux.Handle("/dns/example.com/removeRR", testHandler(params)) + client := servermock.NewBuilder[*Client](setupClient, + servermock.CheckHeader(). + WithContentTypeFromURLEncoded(). + With(authenticationHeader, "user;1692475113;0123456789ABCDEF;699f01f077ca487bd66ac370d6dfc5b122c65522"), + ). + Route("POST /dns/example.com/removeRR", nil, + servermock.CheckForm().Strict(). + With("data", "txtTXTtxt"). + With("name", "sub"). + With("type", "TXT"), + ). + Build(t) record := Record{ Name: "sub", @@ -138,9 +95,15 @@ func TestClient_RemoveRecord(t *testing.T) { } func TestClient_RemoveRecord_error(t *testing.T) { - client, mux := setupTest(t) - - mux.Handle("/dns/example.com/removeRR", testErrorHandler()) + client := servermock.NewBuilder[*Client](setupClient, + servermock.CheckHeader(). + WithContentTypeFromURLEncoded(). + With(authenticationHeader, "user;1692475113;0123456789ABCDEF;699f01f077ca487bd66ac370d6dfc5b122c65522"), + ). + Route("POST /dns/example.com/removeRR", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) record := Record{ Name: "sub", diff --git a/providers/dns/netcup/internal/client_live_test.go b/providers/dns/netcup/internal/client_live_test.go new file mode 100644 index 000000000..3cf6c8c0b --- /dev/null +++ b/providers/dns/netcup/internal/client_live_test.go @@ -0,0 +1,137 @@ +package internal + +import ( + "fmt" + "strconv" + "strings" + "testing" + + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var envTest = tester.NewEnvTest( + "NETCUP_CUSTOMER_NUMBER", + "NETCUP_API_KEY", + "NETCUP_API_PASSWORD"). + WithDomain("NETCUP_DOMAIN") + +func TestClient_GetDNSRecords_Live(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + // Setup + envTest.RestoreEnv() + + client, err := NewClient( + envTest.GetValue("NETCUP_CUSTOMER_NUMBER"), + envTest.GetValue("NETCUP_API_KEY"), + envTest.GetValue("NETCUP_API_PASSWORD")) + require.NoError(t, err) + + ctx, err := client.CreateSessionContext(t.Context()) + require.NoError(t, err) + + info := dns01.GetChallengeInfo(envTest.GetDomain(), "123d==") + + zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + require.NoError(t, err, "error finding DNSZone") + + zone = dns01.UnFqdn(zone) + + // TestMethod + _, err = client.GetDNSRecords(ctx, zone) + require.NoError(t, err) + + // Tear down + err = client.Logout(ctx) + require.NoError(t, err) +} + +func TestClient_UpdateDNSRecord_Live(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + // Setup + envTest.RestoreEnv() + + client, err := NewClient( + envTest.GetValue("NETCUP_CUSTOMER_NUMBER"), + envTest.GetValue("NETCUP_API_KEY"), + envTest.GetValue("NETCUP_API_PASSWORD")) + require.NoError(t, err) + + ctx, err := client.CreateSessionContext(t.Context()) + require.NoError(t, err) + + info := dns01.GetChallengeInfo(envTest.GetDomain(), "123d==") + + zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + require.NotErrorIs(t, err, fmt.Errorf("error finding DNSZone, %w", err)) + + hostname := strings.Replace(info.EffectiveFQDN, "."+zone, "", 1) + + record := DNSRecord{ + Hostname: hostname, + RecordType: "TXT", + Destination: "asdf5678", + DeleteRecord: false, + } + + // test + zone = dns01.UnFqdn(zone) + + err = client.UpdateDNSRecord(ctx, zone, []DNSRecord{record}) + require.NoError(t, err) + + records, err := client.GetDNSRecords(ctx, zone) + require.NoError(t, err) + + recordIdx, err := GetDNSRecordIdx(records, record) + require.NoError(t, err) + + assert.Equal(t, record.Hostname, records[recordIdx].Hostname) + assert.Equal(t, record.RecordType, records[recordIdx].RecordType) + assert.Equal(t, record.Destination, records[recordIdx].Destination) + assert.Equal(t, record.DeleteRecord, records[recordIdx].DeleteRecord) + + records[recordIdx].DeleteRecord = true + + // Tear down + err = client.UpdateDNSRecord(ctx, envTest.GetDomain(), []DNSRecord{records[recordIdx]}) + require.NoError(t, err, "Did not remove record! Please do so yourself.") + + err = client.Logout(ctx) + require.NoError(t, err) +} + +func TestLiveClientAuth(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + // Setup + envTest.RestoreEnv() + + client, err := NewClient( + envTest.GetValue("NETCUP_CUSTOMER_NUMBER"), + envTest.GetValue("NETCUP_API_KEY"), + envTest.GetValue("NETCUP_API_PASSWORD")) + require.NoError(t, err) + + for i := range 4 { + t.Run("Test_"+strconv.Itoa(i+1), func(t *testing.T) { + t.Parallel() + + ctx, err := client.CreateSessionContext(t.Context()) + require.NoError(t, err) + + err = client.Logout(ctx) + require.NoError(t, err) + }) + } +} diff --git a/providers/dns/netcup/internal/client_test.go b/providers/dns/netcup/internal/client_test.go index 501629e8f..a1c91aac4 100644 --- a/providers/dns/netcup/internal/client_test.go +++ b/providers/dns/netcup/internal/client_test.go @@ -1,40 +1,30 @@ package internal import ( - "bytes" - "fmt" - "io" "net/http" "net/http/httptest" - "strings" "testing" - "github.com/go-acme/lego/v4/challenge/dns01" - "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -var envTest = tester.NewEnvTest( - "NETCUP_CUSTOMER_NUMBER", - "NETCUP_API_KEY", - "NETCUP_API_PASSWORD"). - WithDomain("NETCUP_DOMAIN") +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient("a", "b", "c") + if err != nil { + return nil, err + } -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() + client.baseURL = server.URL + client.HTTPClient = server.Client() - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client, err := NewClient("a", "b", "c") - require.NoError(t, err) - - client.baseURL = server.URL - client.HTTPClient = server.Client() - - return client, mux + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(), + ) } func TestGetDNSRecordIdx(t *testing.T) { @@ -139,59 +129,10 @@ func TestGetDNSRecordIdx(t *testing.T) { } func TestClient_GetDNSRecords(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) { - raw, err := io.ReadAll(req.Body) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - if string(bytes.TrimSpace(raw)) != `{"action":"infoDnsRecords","param":{"domainname":"example.com","customernumber":"a","apikey":"b","apisessionid":""}}` { - http.Error(rw, fmt.Sprintf("invalid request body: %s", string(raw)), http.StatusBadRequest) - return - } - - response := ` - { - "serverrequestid":"srv-request-id", - "clientrequestid":"", - "action":"infoDnsRecords", - "status":"success", - "statuscode":2000, - "shortmessage":"Login successful", - "longmessage":"Session has been created successful.", - "responsedata":{ - "apisessionid":"api-session-id", - "dnsrecords":[ - { - "id":"1", - "hostname":"example.com", - "type":"TXT", - "priority":"1", - "destination":"bGVnbzE=", - "state":"yes", - "ttl":300 - }, - { - "id":"2", - "hostname":"example2.com", - "type":"TXT", - "priority":"1", - "destination":"bGVnbw==", - "state":"yes", - "ttl":300 - } - ] - } - }` - _, err = rw.Write([]byte(response)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder(). + Route("POST /", servermock.ResponseFromFixture("get_dns_records.json"), + servermock.CheckRequestJSONBodyFromFile("get_dns_records-request.json")). + Build(t) expected := []DNSRecord{{ ID: 1, @@ -219,67 +160,24 @@ func TestClient_GetDNSRecords(t *testing.T) { func TestClient_GetDNSRecords_errors(t *testing.T) { testCases := []struct { - desc string - handler func(rw http.ResponseWriter, req *http.Request) + desc string + handler http.Handler + expected string }{ { - desc: "HTTP error", - handler: func(rw http.ResponseWriter, _ *http.Request) { - http.Error(rw, "error message", http.StatusInternalServerError) - }, + desc: "HTTP error", + handler: servermock.Noop().WithStatusCode(http.StatusInternalServerError), + expected: `error when sending the request: unexpected status code: [status code: 500] body: `, }, { - desc: "API error", - handler: func(rw http.ResponseWriter, _ *http.Request) { - response := ` - { - "serverrequestid":"YxTr4EzdbJ101T211zR4yzUEMVE", - "clientrequestid":"", - "action":"infoDnsRecords", - "status":"error", - "statuscode":4013, - "shortmessage":"Validation Error.", - "longmessage":"Message is empty.", - "responsedata":"" - }` - _, err := rw.Write([]byte(response)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }, + desc: "API error", + handler: servermock.ResponseFromFixture("get_dns_records_error.json"), + expected: `error when sending the request: an error occurred during the action infoDnsRecords: [Status=error, StatusCode=4013, ShortMessage=Validation Error., LongMessage=Message is empty.]`, }, { - desc: "responsedata marshaling error", - handler: func(rw http.ResponseWriter, req *http.Request) { - raw, err := io.ReadAll(req.Body) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - if string(raw) != `{"action":"infoDnsRecords","param":{"domainname":"example.com","customernumber":"a","apikey":"b","apisessionid":"api-session-id"}}` { - http.Error(rw, fmt.Sprintf("invalid request body: %s", string(raw)), http.StatusBadRequest) - return - } - - response := ` - { - "serverrequestid":"srv-request-id", - "clientrequestid":"", - "action":"infoDnsRecords", - "status":"success", - "statuscode":2000, - "shortmessage":"Login successful", - "longmessage":"Session has been created successful.", - "responsedata":"" - }` - _, err = rw.Write([]byte(response)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }, + desc: "responsedata marshaling error", + handler: servermock.ResponseFromFixture("get_dns_records_error_unmarshal.json"), + expected: `error when sending the request: unable to unmarshal response: [status code: 200] body: "" error: json: cannot unmarshal string into Go value of type internal.InfoDNSRecordsResponse`, }, } @@ -287,104 +185,13 @@ func TestClient_GetDNSRecords_errors(t *testing.T) { t.Run(test.desc, func(t *testing.T) { t.Parallel() - client, mux := setupTest(t) - - mux.HandleFunc("/", test.handler) + client := mockBuilder(). + Route("POST /", test.handler). + Build(t) records, err := client.GetDNSRecords(t.Context(), "example.com") - require.Error(t, err) + require.EqualError(t, err, test.expected) assert.Empty(t, records) }) } } - -func TestClient_GetDNSRecords_Live(t *testing.T) { - if !envTest.IsLiveTest() { - t.Skip("skipping live test") - } - - // Setup - envTest.RestoreEnv() - - client, err := NewClient( - envTest.GetValue("NETCUP_CUSTOMER_NUMBER"), - envTest.GetValue("NETCUP_API_KEY"), - envTest.GetValue("NETCUP_API_PASSWORD")) - require.NoError(t, err) - - ctx, err := client.CreateSessionContext(t.Context()) - require.NoError(t, err) - - info := dns01.GetChallengeInfo(envTest.GetDomain(), "123d==") - - zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) - require.NoError(t, err, "error finding DNSZone") - - zone = dns01.UnFqdn(zone) - - // TestMethod - _, err = client.GetDNSRecords(ctx, zone) - require.NoError(t, err) - - // Tear down - err = client.Logout(ctx) - require.NoError(t, err) -} - -func TestClient_UpdateDNSRecord_Live(t *testing.T) { - if !envTest.IsLiveTest() { - t.Skip("skipping live test") - } - - // Setup - envTest.RestoreEnv() - - client, err := NewClient( - envTest.GetValue("NETCUP_CUSTOMER_NUMBER"), - envTest.GetValue("NETCUP_API_KEY"), - envTest.GetValue("NETCUP_API_PASSWORD")) - require.NoError(t, err) - - ctx, err := client.CreateSessionContext(t.Context()) - require.NoError(t, err) - - info := dns01.GetChallengeInfo(envTest.GetDomain(), "123d==") - - zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) - require.NotErrorIs(t, err, fmt.Errorf("error finding DNSZone, %w", err)) - - hostname := strings.Replace(info.EffectiveFQDN, "."+zone, "", 1) - - record := DNSRecord{ - Hostname: hostname, - RecordType: "TXT", - Destination: "asdf5678", - DeleteRecord: false, - } - - // test - zone = dns01.UnFqdn(zone) - - err = client.UpdateDNSRecord(ctx, zone, []DNSRecord{record}) - require.NoError(t, err) - - records, err := client.GetDNSRecords(ctx, zone) - require.NoError(t, err) - - recordIdx, err := GetDNSRecordIdx(records, record) - require.NoError(t, err) - - assert.Equal(t, record.Hostname, records[recordIdx].Hostname) - assert.Equal(t, record.RecordType, records[recordIdx].RecordType) - assert.Equal(t, record.Destination, records[recordIdx].Destination) - assert.Equal(t, record.DeleteRecord, records[recordIdx].DeleteRecord) - - records[recordIdx].DeleteRecord = true - - // Tear down - err = client.UpdateDNSRecord(ctx, envTest.GetDomain(), []DNSRecord{records[recordIdx]}) - require.NoError(t, err, "Did not remove record! Please do so yourself.") - - err = client.Logout(ctx) - require.NoError(t, err) -} diff --git a/providers/dns/netcup/internal/fixtures/get_dns_records-request.json b/providers/dns/netcup/internal/fixtures/get_dns_records-request.json new file mode 100644 index 000000000..bcf8e5310 --- /dev/null +++ b/providers/dns/netcup/internal/fixtures/get_dns_records-request.json @@ -0,0 +1,9 @@ +{ + "action": "infoDnsRecords", + "param": { + "domainname": "example.com", + "customernumber": "a", + "apikey": "b", + "apisessionid": "" + } +} diff --git a/providers/dns/netcup/internal/fixtures/get_dns_records.json b/providers/dns/netcup/internal/fixtures/get_dns_records.json new file mode 100644 index 000000000..e521a8e24 --- /dev/null +++ b/providers/dns/netcup/internal/fixtures/get_dns_records.json @@ -0,0 +1,32 @@ +{ + "serverrequestid": "srv-request-id", + "clientrequestid": "", + "action": "infoDnsRecords", + "status": "success", + "statuscode": 2000, + "shortmessage": "Login successful", + "longmessage": "Session has been created successful.", + "responsedata": { + "apisessionid": "api-session-id", + "dnsrecords": [ + { + "id": "1", + "hostname": "example.com", + "type": "TXT", + "priority": "1", + "destination": "bGVnbzE=", + "state": "yes", + "ttl": 300 + }, + { + "id": "2", + "hostname": "example2.com", + "type": "TXT", + "priority": "1", + "destination": "bGVnbw==", + "state": "yes", + "ttl": 300 + } + ] + } +} diff --git a/providers/dns/netcup/internal/fixtures/get_dns_records_error.json b/providers/dns/netcup/internal/fixtures/get_dns_records_error.json new file mode 100644 index 000000000..3ba472366 --- /dev/null +++ b/providers/dns/netcup/internal/fixtures/get_dns_records_error.json @@ -0,0 +1,10 @@ +{ + "serverrequestid":"YxTr4EzdbJ101T211zR4yzUEMVE", + "clientrequestid":"", + "action":"infoDnsRecords", + "status":"error", + "statuscode":4013, + "shortmessage":"Validation Error.", + "longmessage":"Message is empty.", + "responsedata":"" +} diff --git a/providers/dns/netcup/internal/fixtures/get_dns_records_error_unmarshal.json b/providers/dns/netcup/internal/fixtures/get_dns_records_error_unmarshal.json new file mode 100644 index 000000000..f8f91329f --- /dev/null +++ b/providers/dns/netcup/internal/fixtures/get_dns_records_error_unmarshal.json @@ -0,0 +1,10 @@ +{ + "serverrequestid":"srv-request-id", + "clientrequestid":"", + "action":"infoDnsRecords", + "status":"success", + "statuscode":2000, + "shortmessage":"Login successful", + "longmessage":"Session has been created successful.", + "responsedata":"" +} diff --git a/providers/dns/netcup/internal/fixtures/login-request.json b/providers/dns/netcup/internal/fixtures/login-request.json new file mode 100644 index 000000000..1e287dfe0 --- /dev/null +++ b/providers/dns/netcup/internal/fixtures/login-request.json @@ -0,0 +1,8 @@ +{ + "action": "login", + "param": { + "customernumber": "a", + "apikey": "b", + "apipassword": "c" + } +} diff --git a/providers/dns/netcup/internal/fixtures/login.json b/providers/dns/netcup/internal/fixtures/login.json new file mode 100644 index 000000000..a66979544 --- /dev/null +++ b/providers/dns/netcup/internal/fixtures/login.json @@ -0,0 +1,12 @@ +{ + "serverrequestid": "srv-request-id", + "clientrequestid": "", + "action": "login", + "status": "success", + "statuscode": 2000, + "shortmessage": "Login successful", + "longmessage": "Session has been created successful.", + "responsedata": { + "apisessionid": "api-session-id" + } +} diff --git a/providers/dns/netcup/internal/fixtures/login_error.json b/providers/dns/netcup/internal/fixtures/login_error.json new file mode 100644 index 000000000..a32568f78 --- /dev/null +++ b/providers/dns/netcup/internal/fixtures/login_error.json @@ -0,0 +1,10 @@ +{ + "serverrequestid":"YxTr4EzdbJ101T211zR4yzUEMVE", + "clientrequestid":"", + "action":"login", + "status":"error", + "statuscode":4013, + "shortmessage":"Validation Error.", + "longmessage":"Message is empty.", + "responsedata":"" +} diff --git a/providers/dns/netcup/internal/fixtures/login_error_unmarshal.json b/providers/dns/netcup/internal/fixtures/login_error_unmarshal.json new file mode 100644 index 000000000..96e7cbd0c --- /dev/null +++ b/providers/dns/netcup/internal/fixtures/login_error_unmarshal.json @@ -0,0 +1,10 @@ +{ + "serverrequestid": "srv-request-id", + "clientrequestid": "", + "action": "login", + "status": "success", + "statuscode": 2000, + "shortmessage": "Login successful", + "longmessage": "Session has been created successful.", + "responsedata": "" +} diff --git a/providers/dns/netcup/internal/fixtures/logout-request.json b/providers/dns/netcup/internal/fixtures/logout-request.json new file mode 100644 index 000000000..add759c3a --- /dev/null +++ b/providers/dns/netcup/internal/fixtures/logout-request.json @@ -0,0 +1,8 @@ +{ + "action": "logout", + "param": { + "customernumber": "a", + "apikey": "b", + "apisessionid": "session-id" + } +} diff --git a/providers/dns/netcup/internal/fixtures/logout.json b/providers/dns/netcup/internal/fixtures/logout.json new file mode 100644 index 000000000..50881fff3 --- /dev/null +++ b/providers/dns/netcup/internal/fixtures/logout.json @@ -0,0 +1,10 @@ +{ + "serverrequestid": "request-id", + "clientrequestid": "", + "action": "logout", + "status": "success", + "statuscode": 2000, + "shortmessage": "Logout successful", + "longmessage": "Session has been terminated successful.", + "responsedata": "" +} diff --git a/providers/dns/netcup/internal/fixtures/logout_error.json b/providers/dns/netcup/internal/fixtures/logout_error.json new file mode 100644 index 000000000..a2de32da1 --- /dev/null +++ b/providers/dns/netcup/internal/fixtures/logout_error.json @@ -0,0 +1,10 @@ +{ + "serverrequestid":"YxTr4EzdbJ101T211zR4yzUEMVE", + "clientrequestid":"", + "action":"logout", + "status":"error", + "statuscode":4013, + "shortmessage":"Validation Error.", + "longmessage":"Message is empty.", + "responsedata":"" +} diff --git a/providers/dns/netcup/internal/session_test.go b/providers/dns/netcup/internal/session_test.go index ceec56708..27442b347 100644 --- a/providers/dns/netcup/internal/session_test.go +++ b/providers/dns/netcup/internal/session_test.go @@ -1,14 +1,11 @@ package internal import ( - "bytes" "context" - "fmt" - "io" "net/http" - "strconv" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -20,40 +17,10 @@ func mockContext(t *testing.T) context.Context { } func TestClient_Login(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) { - raw, err := io.ReadAll(req.Body) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - if string(bytes.TrimSpace(raw)) != `{"action":"login","param":{"customernumber":"a","apikey":"b","apipassword":"c"}}` { - http.Error(rw, fmt.Sprintf("invalid request body: %s", string(raw)), http.StatusBadRequest) - return - } - - response := ` - { - "serverrequestid": "srv-request-id", - "clientrequestid": "", - "action": "login", - "status": "success", - "statuscode": 2000, - "shortmessage": "Login successful", - "longmessage": "Session has been created successful.", - "responsedata": { - "apisessionid": "api-session-id" - } - } - ` - _, err = rw.Write([]byte(response)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder(). + Route("POST /", servermock.ResponseFromFixture("login.json"), + servermock.CheckRequestJSONBodyFromFile("login-request.json")). + Build(t) sessionID, err := client.login(t.Context()) require.NoError(t, err) @@ -63,56 +30,24 @@ func TestClient_Login(t *testing.T) { func TestClient_Login_errors(t *testing.T) { testCases := []struct { - desc string - handler func(rw http.ResponseWriter, req *http.Request) + desc string + handler http.Handler + expected string }{ { - desc: "HTTP error", - handler: func(rw http.ResponseWriter, _ *http.Request) { - http.Error(rw, "error message", http.StatusInternalServerError) - }, + desc: "HTTP error", + handler: servermock.Noop().WithStatusCode(http.StatusInternalServerError), + expected: `loging error: unexpected status code: [status code: 500] body: `, }, { - desc: "API error", - handler: func(rw http.ResponseWriter, _ *http.Request) { - response := ` - { - "serverrequestid":"YxTr4EzdbJ101T211zR4yzUEMVE", - "clientrequestid":"", - "action":"login", - "status":"error", - "statuscode":4013, - "shortmessage":"Validation Error.", - "longmessage":"Message is empty.", - "responsedata":"" - }` - _, err := rw.Write([]byte(response)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }, + desc: "API error", + handler: servermock.ResponseFromFixture("login_error.json"), + expected: `loging error: an error occurred during the action login: [Status=error, StatusCode=4013, ShortMessage=Validation Error., LongMessage=Message is empty.]`, }, { - desc: "responsedata marshaling error", - handler: func(rw http.ResponseWriter, _ *http.Request) { - response := ` - { - "serverrequestid": "srv-request-id", - "clientrequestid": "", - "action": "login", - "status": "success", - "statuscode": 2000, - "shortmessage": "Login successful", - "longmessage": "Session has been created successful.", - "responsedata": "" - }` - _, err := rw.Write([]byte(response)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }, + desc: "responsedata marshaling error", + handler: servermock.ResponseFromFixture("login_error_unmarshal.json"), + expected: `loging error: unable to unmarshal response: [status code: 200] body: "" error: json: cannot unmarshal string into Go value of type internal.LoginResponse`, }, } @@ -120,49 +55,22 @@ func TestClient_Login_errors(t *testing.T) { t.Run(test.desc, func(t *testing.T) { t.Parallel() - client, mux := setupTest(t) - - mux.HandleFunc("/", test.handler) + client := mockBuilder(). + Route("POST /", test.handler). + Build(t) sessionID, err := client.login(t.Context()) - assert.Error(t, err) + assert.EqualError(t, err, test.expected) assert.Empty(t, sessionID) }) } } func TestClient_Logout(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) { - raw, err := io.ReadAll(req.Body) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - if string(bytes.TrimSpace(raw)) != `{"action":"logout","param":{"customernumber":"a","apikey":"b","apisessionid":"session-id"}}` { - http.Error(rw, fmt.Sprintf("invalid request body: %s", string(raw)), http.StatusBadRequest) - return - } - - response := ` - { - "serverrequestid": "request-id", - "clientrequestid": "", - "action": "logout", - "status": "success", - "statuscode": 2000, - "shortmessage": "Logout successful", - "longmessage": "Session has been terminated successful.", - "responsedata": "" - }` - _, err = rw.Write([]byte(response)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder(). + Route("POST /", servermock.ResponseFromFixture("logout.json"), + servermock.CheckRequestJSONBodyFromFile("logout-request.json")). + Build(t) err := client.Logout(mockContext(t)) require.NoError(t, err) @@ -170,35 +78,17 @@ func TestClient_Logout(t *testing.T) { func TestClient_Logout_errors(t *testing.T) { testCases := []struct { - desc string - handler func(rw http.ResponseWriter, req *http.Request) + desc string + handler http.Handler + expected string }{ { - desc: "HTTP error", - handler: func(rw http.ResponseWriter, _ *http.Request) { - http.Error(rw, "error message", http.StatusInternalServerError) - }, + desc: "HTTP error", + handler: servermock.Noop().WithStatusCode(http.StatusInternalServerError), }, { - desc: "API error", - handler: func(rw http.ResponseWriter, _ *http.Request) { - response := ` - { - "serverrequestid":"YxTr4EzdbJ101T211zR4yzUEMVE", - "clientrequestid":"", - "action":"logout", - "status":"error", - "statuscode":4013, - "shortmessage":"Validation Error.", - "longmessage":"Message is empty.", - "responsedata":"" - }` - _, err := rw.Write([]byte(response)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }, + desc: "API error", + handler: servermock.ResponseFromFixture("login_error.json"), }, } @@ -206,39 +96,12 @@ func TestClient_Logout_errors(t *testing.T) { t.Run(test.desc, func(t *testing.T) { t.Parallel() - client, mux := setupTest(t) - - mux.HandleFunc("/", test.handler) + client := mockBuilder(). + Route("POST /", test.handler). + Build(t) err := client.Logout(t.Context()) require.Error(t, err) }) } } - -func TestLiveClientAuth(t *testing.T) { - if !envTest.IsLiveTest() { - t.Skip("skipping live test") - } - - // Setup - envTest.RestoreEnv() - - client, err := NewClient( - envTest.GetValue("NETCUP_CUSTOMER_NUMBER"), - envTest.GetValue("NETCUP_API_KEY"), - envTest.GetValue("NETCUP_API_PASSWORD")) - require.NoError(t, err) - - for i := range 4 { - t.Run("Test_"+strconv.Itoa(i+1), func(t *testing.T) { - t.Parallel() - - ctx, err := client.CreateSessionContext(t.Context()) - require.NoError(t, err) - - err = client.Logout(ctx) - require.NoError(t, err) - }) - } -} diff --git a/providers/dns/netlify/internal/client_test.go b/providers/dns/netlify/internal/client_test.go index a1e9e09a3..b19a8f071 100644 --- a/providers/dns/netlify/internal/client_test.go +++ b/providers/dns/netlify/internal/client_test.go @@ -1,61 +1,33 @@ package internal import ( - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, token string) (*Client, *http.ServeMux) { - t.Helper() +func setupClient(token string) func(server *httptest.Server) (*Client, error) { + return func(server *httptest.Server) (*Client, error) { + client := NewClient(OAuthStaticAccessToken(server.Client(), token)) + client.baseURL, _ = url.Parse(server.URL) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := NewClient(OAuthStaticAccessToken(server.Client(), token)) - client.baseURL, _ = url.Parse(server.URL) - - return client, mux + return client, nil + } } func TestClient_GetRecords(t *testing.T) { - client, mux := setupTest(t, "tokenA") - - mux.HandleFunc("/dns_zones/zoneID/dns_records", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, "unsupported method", http.StatusMethodNotAllowed) - return - } - - auth := req.Header.Get("Authorization") - if auth != "Bearer tokenA" { - http.Error(rw, fmt.Sprintf("invali token: %s", auth), http.StatusUnauthorized) - return - } - - rw.Header().Set("Content-Type", "application/json; charset=utf-8") - - file, err := os.Open("./fixtures/get_records.json") - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := servermock.NewBuilder[*Client](setupClient("tokenA"), + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("Bearer tokenA"), + ). + Route("GET /dns_zones/zoneID/dns_records", + servermock.ResponseFromFixture("get_records.json")). + Build(t) records, err := client.GetRecords(t.Context(), "zoneID") require.NoError(t, err) @@ -69,36 +41,16 @@ func TestClient_GetRecords(t *testing.T) { } func TestClient_CreateRecord(t *testing.T) { - client, mux := setupTest(t, "tokenB") - - mux.HandleFunc("/dns_zones/zoneID/dns_records", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, "unsupported method", http.StatusMethodNotAllowed) - return - } - - auth := req.Header.Get("Authorization") - if auth != "Bearer tokenB" { - http.Error(rw, fmt.Sprintf("invali token: %s", auth), http.StatusUnauthorized) - return - } - - rw.Header().Set("Content-Type", "application/json; charset=utf-8") - rw.WriteHeader(http.StatusCreated) - - file, err := os.Open("./fixtures/create_record.json") - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := servermock.NewBuilder[*Client](setupClient("tokenB"), + servermock.CheckHeader(). + WithAccept("application/json"). + WithContentType("application/json; charset=utf-8"). + WithAuthorization("Bearer tokenB"), + ). + Route("POST /dns_zones/zoneID/dns_records", + servermock.ResponseFromFixture("create_record.json"). + WithStatusCode(http.StatusCreated)). + Build(t) record := DNSRecord{ Hostname: "_acme-challenge.example.com", @@ -122,22 +74,14 @@ func TestClient_CreateRecord(t *testing.T) { } func TestClient_RemoveRecord(t *testing.T) { - client, mux := setupTest(t, "tokenC") - - mux.HandleFunc("/dns_zones/zoneID/dns_records/recordID", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodDelete { - http.Error(rw, "unsupported method", http.StatusMethodNotAllowed) - return - } - - auth := req.Header.Get("Authorization") - if auth != "Bearer tokenC" { - http.Error(rw, fmt.Sprintf("invali token: %s", auth), http.StatusUnauthorized) - return - } - - rw.WriteHeader(http.StatusNoContent) - }) + client := servermock.NewBuilder[*Client](setupClient("tokenC"), + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("Bearer tokenC"), + ). + Route("DELETE /dns_zones/zoneID/dns_records/recordID", + servermock.Noop(). + WithStatusCode(http.StatusNoContent)). + Build(t) err := client.RemoveRecord(t.Context(), "zoneID", "recordID") require.NoError(t, err) diff --git a/providers/dns/nicmanager/internal/client_test.go b/providers/dns/nicmanager/internal/client_test.go index 9c8679bea..1eb7d5a36 100644 --- a/providers/dns/nicmanager/internal/client_test.go +++ b/providers/dns/nicmanager/internal/client_test.go @@ -1,21 +1,42 @@ package internal import ( - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + opts := Options{ + Login: "l", + Username: "u", + Password: "p", + OTP: "2hsn", + } + + client := NewClient(opts) + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) + + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithBasicAuth("l.u", "p"). + WithRegexp(headerTOTPToken, `\d{6}`)) +} + func TestClient_GetZone(t *testing.T) { - client := setupTest(t, "/anycast/nicmanager-anycastdns4.net", testHandler(http.MethodGet, http.StatusOK, "zone.json")) + client := mockBuilder(). + Route("GET /anycast/nicmanager-anycastdns4.net", + servermock.ResponseFromFixture("zone.json")). + Build(t) zone, err := client.GetZone(t.Context(), "nicmanager-anycastdns4.net") require.NoError(t, err) @@ -38,14 +59,22 @@ func TestClient_GetZone(t *testing.T) { } func TestClient_GetZone_error(t *testing.T) { - client := setupTest(t, "/anycast/foo", testHandler(http.MethodGet, http.StatusNotFound, "error.json")) + client := mockBuilder(). + Route("GET /anycast/foo", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusNotFound)). + Build(t) _, err := client.GetZone(t.Context(), "foo") - require.Error(t, err) + require.EqualError(t, err, "404: Not Found") } func TestClient_AddRecord(t *testing.T) { - client := setupTest(t, "/anycast/zonedomain.tld/records", testHandler(http.MethodPost, http.StatusAccepted, "error.json")) + client := mockBuilder(). + Route("POST /anycast/zonedomain.tld/records", + servermock.Noop(). + WithStatusCode(http.StatusAccepted)). + Build(t) record := RecordCreateUpdate{ Type: "TXT", @@ -59,7 +88,11 @@ func TestClient_AddRecord(t *testing.T) { } func TestClient_AddRecord_error(t *testing.T) { - client := setupTest(t, "/anycast/zonedomain.tld", testHandler(http.MethodPost, http.StatusUnauthorized, "error.json")) + client := mockBuilder(). + Route("POST /anycast/zonedomain.tld/records", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) record := RecordCreateUpdate{ Type: "TXT", @@ -69,77 +102,27 @@ func TestClient_AddRecord_error(t *testing.T) { } err := client.AddRecord(t.Context(), "zonedomain.tld", record) - require.Error(t, err) + require.EqualError(t, err, "401: Not Found") } func TestClient_DeleteRecord(t *testing.T) { - client := setupTest(t, "/anycast/zonedomain.tld/records/6", testHandler(http.MethodDelete, http.StatusAccepted, "error.json")) + client := mockBuilder(). + Route("DELETE /anycast/zonedomain.tld/records/6", + servermock.Noop(). + WithStatusCode(http.StatusAccepted)). + Build(t) err := client.DeleteRecord(t.Context(), "zonedomain.tld", 6) require.NoError(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { - client := setupTest(t, "/anycast/zonedomain.tld/records/6", testHandler(http.MethodDelete, http.StatusNoContent, "")) + client := mockBuilder(). + Route("DELETE /anycast/zonedomain.tld/records/6", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusNotFound)). + Build(t) - err := client.DeleteRecord(t.Context(), "zonedomain.tld", 7) - require.Error(t, err) -} - -func setupTest(t *testing.T, path string, handler http.Handler) *Client { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.Handle(path, handler) - - opts := Options{ - Login: "foo", - Username: "bar", - Password: "foo", - OTP: "2hsn", - } - - client := NewClient(opts) - client.HTTPClient = server.Client() - client.baseURL, _ = url.Parse(server.URL) - - return client -} - -func testHandler(method string, statusCode int, filename string) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf(`{"message":"unsupported method: %s"}`, req.Method), http.StatusMethodNotAllowed) - return - } - - username, password, ok := req.BasicAuth() - if !ok || username != "foo.bar" || password != "foo" { - http.Error(rw, `{"message":"Unauthenticated"}`, http.StatusUnauthorized) - return - } - - rw.WriteHeader(statusCode) - - if statusCode == http.StatusNoContent { - return - } - - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, fmt.Sprintf(`{"message":"%v"}`, err), http.StatusInternalServerError) - return - } - - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, fmt.Sprintf(`{"message":"%v"}`, err), http.StatusInternalServerError) - return - } - } + err := client.DeleteRecord(t.Context(), "zonedomain.tld", 6) + require.EqualError(t, err, "404: Not Found") } diff --git a/providers/dns/nicru/internal/client_test.go b/providers/dns/nicru/internal/client_test.go index d49aa4014..f01300406 100644 --- a/providers/dns/nicru/internal/client_test.go +++ b/providers/dns/nicru/internal/client_test.go @@ -1,63 +1,36 @@ package internal import ( - "fmt" - "io" - "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, pattern string, handler http.HandlerFunc) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient(server.Client()) + if err != nil { + return nil, err + } - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) + client.baseURL, _ = url.Parse(server.URL) - mux.HandleFunc(pattern, handler) - - client, err := NewClient(server.Client()) - require.NoError(t, err) - - client.baseURL, _ = url.Parse(server.URL) - - return client -} - -func writeFixtures(method, filename string, status int) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = file.Close() }() - - rw.WriteHeader(status) - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - } + return client, nil + }, + servermock.CheckHeader(). + WithAccept("text/xml"), + ) } func TestClient_GetServices(t *testing.T) { - client := setupTest(t, "/services", - writeFixtures(http.MethodGet, "services_GET.xml", http.StatusOK)) + client := mockBuilder(). + Route("GET /services", servermock.ResponseFromFixture("services_GET.xml")). + Build(t) zones, err := client.GetServices(t.Context()) require.NoError(t, err) @@ -91,8 +64,9 @@ func TestClient_GetServices(t *testing.T) { } func TestClient_ListZones(t *testing.T) { - client := setupTest(t, "/zones", - writeFixtures(http.MethodGet, "zones_all_GET.xml", http.StatusOK)) + client := mockBuilder(). + Route("GET /zones", servermock.ResponseFromFixture("zones_all_GET.xml")). + Build(t) zones, err := client.ListZones(t.Context()) require.NoError(t, err) @@ -137,8 +111,9 @@ func TestClient_ListZones(t *testing.T) { } func TestClient_ListZones_error(t *testing.T) { - client := setupTest(t, "/zones", - writeFixtures(http.MethodGet, "errors.xml", http.StatusOK)) + client := mockBuilder(). + Route("GET /zones", servermock.ResponseFromFixture("errors.xml")). + Build(t) _, err := client.ListZones(t.Context()) require.ErrorIs(t, err, Error{ @@ -148,8 +123,10 @@ func TestClient_ListZones_error(t *testing.T) { } func TestClient_GetZonesByService(t *testing.T) { - client := setupTest(t, "/services/test/zones", - writeFixtures(http.MethodGet, "zones_GET.xml", http.StatusOK)) + client := mockBuilder(). + Route("GET /services/test/zones", + servermock.ResponseFromFixture("zones_GET.xml")). + Build(t) zones, err := client.GetZonesByService(t.Context(), "test") require.NoError(t, err) @@ -194,8 +171,10 @@ func TestClient_GetZonesByService(t *testing.T) { } func TestClient_GetZonesByService_error(t *testing.T) { - client := setupTest(t, "/services/test/zones", - writeFixtures(http.MethodGet, "errors.xml", http.StatusOK)) + client := mockBuilder(). + Route("GET /services/test/zones", + servermock.ResponseFromFixture("errors.xml")). + Build(t) _, err := client.GetZonesByService(t.Context(), "test") require.ErrorIs(t, err, Error{ @@ -205,8 +184,10 @@ func TestClient_GetZonesByService_error(t *testing.T) { } func TestClient_GetRecords(t *testing.T) { - client := setupTest(t, "/services/test/zones/example.com./records", - writeFixtures(http.MethodGet, "records_GET.xml", http.StatusOK)) + client := mockBuilder(). + Route("GET /services/test/zones/example.com./records", + servermock.ResponseFromFixture("records_GET.xml")). + Build(t) records, err := client.GetRecords(t.Context(), "test", "example.com.") require.NoError(t, err) @@ -270,8 +251,10 @@ func TestClient_GetRecords(t *testing.T) { } func TestClient_GetRecords_error(t *testing.T) { - client := setupTest(t, "/services/test/zones/example.com./records", - writeFixtures(http.MethodGet, "errors.xml", http.StatusOK)) + client := mockBuilder(). + Route("GET /services/test/zones/example.com./records", + servermock.ResponseFromFixture("errors.xml")). + Build(t) _, err := client.GetRecords(t.Context(), "test", "example.com.") require.ErrorIs(t, err, Error{ @@ -281,8 +264,12 @@ func TestClient_GetRecords_error(t *testing.T) { } func TestClient_AddRecord(t *testing.T) { - client := setupTest(t, "/services/test/zones/example.com./records", - writeFixtures(http.MethodPut, "records_PUT.xml", http.StatusOK)) + client := mockBuilder(). + Route("PUT /services/test/zones/example.com./records", + servermock.ResponseFromFixture("records_PUT.xml"), + servermock.CheckHeader(). + WithContentType("text/xml")). + Build(t) rrs := []RR{ { @@ -337,8 +324,12 @@ func TestClient_AddRecord(t *testing.T) { } func TestClient_AddRecord_error(t *testing.T) { - client := setupTest(t, "/services/test/zones/example.com./records", - writeFixtures(http.MethodPut, "errors.xml", http.StatusOK)) + client := mockBuilder(). + Route("PUT /services/test/zones/example.com./records", + servermock.ResponseFromFixture("errors.xml"), + servermock.CheckHeader(). + WithContentType("text/xml")). + Build(t) rrs := []RR{ { @@ -361,16 +352,20 @@ func TestClient_AddRecord_error(t *testing.T) { } func TestClient_DeleteRecord(t *testing.T) { - client := setupTest(t, "/services/test/zones/example.com./records/123", - writeFixtures(http.MethodDelete, "record_DELETE.xml", http.StatusUnauthorized)) + client := mockBuilder(). + Route("DELETE /services/test/zones/example.com./records/123", + servermock.ResponseFromFixture("record_DELETE.xml")). + Build(t) err := client.DeleteRecord(t.Context(), "test", "example.com.", "123") require.NoError(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { - client := setupTest(t, "/services/test/zones/example.com./records/123", - writeFixtures(http.MethodDelete, "errors.xml", http.StatusUnauthorized)) + client := mockBuilder(). + Route("DELETE /services/test/zones/example.com./records/123", + servermock.ResponseFromFixture("errors.xml")). + Build(t) err := client.DeleteRecord(t.Context(), "test", "example.com.", "123") require.ErrorIs(t, err, Error{ @@ -380,14 +375,20 @@ func TestClient_DeleteRecord_error(t *testing.T) { } func TestClient_CommitZone(t *testing.T) { - client := setupTest(t, "/services/test/zones/example.com./commit", writeFixtures(http.MethodPost, "commit_POST.xml", http.StatusOK)) + client := mockBuilder(). + Route("POST /services/test/zones/example.com./commit", + servermock.ResponseFromFixture("commit_POST.xml")). + Build(t) err := client.CommitZone(t.Context(), "test", "example.com.") require.NoError(t, err) } func TestClient_CommitZone_error(t *testing.T) { - client := setupTest(t, "/services/test/zones/example.com./commit", writeFixtures(http.MethodPost, "errors.xml", http.StatusOK)) + client := mockBuilder(). + Route("POST /services/test/zones/example.com./commit", + servermock.ResponseFromFixture("errors.xml")). + Build(t) err := client.CommitZone(t.Context(), "test", "example.com.") require.ErrorIs(t, err, Error{ diff --git a/providers/dns/nifcloud/internal/client_test.go b/providers/dns/nifcloud/internal/client_test.go index 91f9d36e2..501265ada 100644 --- a/providers/dns/nifcloud/internal/client_test.go +++ b/providers/dns/nifcloud/internal/client_test.go @@ -1,37 +1,35 @@ package internal import ( - "fmt" "net/http" "net/http/httptest" "net/url" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, responseBody string, statusCode int) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient("A", "B") + if err != nil { + return nil, err + } - handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(statusCode) - _, _ = fmt.Fprintln(w, responseBody) - }) + client.HTTPClient = server.Client() + client.BaseURL, _ = url.Parse(server.URL) - server := httptest.NewServer(handler) - t.Cleanup(server.Close) - - client, err := NewClient("A", "B") - require.NoError(t, err) - - client.HTTPClient = server.Client() - client.BaseURL, _ = url.Parse(server.URL) - - return client + return client, nil + }, + servermock.CheckHeader(). + WithRegexp("X-Nifty-Authorization", "NIFTY3-HTTPS NiftyAccessKeyId=A,Algorithm=HmacSHA1,Signature=.+"), + ) } -func TestChangeResourceRecordSets(t *testing.T) { +func TestClient_ChangeResourceRecordSets(t *testing.T) { responseBody := ` @@ -42,7 +40,10 @@ func TestChangeResourceRecordSets(t *testing.T) { ` - client := setupTest(t, responseBody, http.StatusOK) + client := mockBuilder(). + Route("POST /", servermock.RawStringResponse(responseBody), + servermock.CheckHeader().WithContentType("text/xml; charset=utf-8")). + Build(t) res, err := client.ChangeResourceRecordSets(t.Context(), "example.com", ChangeResourceRecordSetsRequest{}) require.NoError(t, err) @@ -52,7 +53,7 @@ func TestChangeResourceRecordSets(t *testing.T) { assert.Equal(t, "2015-08-05T00:00:00.000Z", res.ChangeInfo.SubmittedAt) } -func TestChangeResourceRecordSetsErrors(t *testing.T) { +func TestClient_ChangeResourceRecordSets_errors(t *testing.T) { testCases := []struct { desc string responseBody string @@ -89,7 +90,13 @@ func TestChangeResourceRecordSetsErrors(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - client := setupTest(t, test.responseBody, test.statusCode) + client := mockBuilder(). + Route("POST /", + servermock.RawStringResponse(test.responseBody). + WithStatusCode(test.statusCode), + servermock.CheckHeader(). + WithContentType("text/xml; charset=utf-8")). + Build(t) res, err := client.ChangeResourceRecordSets(t.Context(), "example.com", ChangeResourceRecordSetsRequest{}) assert.Nil(t, res) @@ -98,7 +105,7 @@ func TestChangeResourceRecordSetsErrors(t *testing.T) { } } -func TestGetChange(t *testing.T) { +func TestClient_GetChange(t *testing.T) { responseBody := ` @@ -109,7 +116,9 @@ func TestGetChange(t *testing.T) { ` - client := setupTest(t, responseBody, http.StatusOK) + client := mockBuilder(). + Route("GET /", servermock.RawStringResponse(responseBody)). + Build(t) res, err := client.GetChange(t.Context(), "12345") require.NoError(t, err) @@ -119,7 +128,7 @@ func TestGetChange(t *testing.T) { assert.Equal(t, "2015-08-05T00:00:00.000Z", res.ChangeInfo.SubmittedAt) } -func TestGetChangeErrors(t *testing.T) { +func TestClient_GetChange_errors(t *testing.T) { testCases := []struct { desc string responseBody string @@ -156,7 +165,10 @@ func TestGetChangeErrors(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - client := setupTest(t, test.responseBody, test.statusCode) + client := mockBuilder(). + Route("GET /", + servermock.RawStringResponse(test.responseBody).WithStatusCode(test.statusCode)). + Build(t) res, err := client.GetChange(t.Context(), "12345") assert.Nil(t, res) diff --git a/providers/dns/njalla/internal/client_test.go b/providers/dns/njalla/internal/client_test.go index 9ad58f24b..ec9309078 100644 --- a/providers/dns/njalla/internal/client_test.go +++ b/providers/dns/njalla/internal/client_test.go @@ -1,75 +1,31 @@ package internal import ( - "encoding/json" - "fmt" - "net/http" "net/http/httptest" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, handler func(http.ResponseWriter, *http.Request)) *Client { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - token := req.Header.Get(authorizationHeader) - if token != "Njalla secret" { - _, _ = rw.Write([]byte(`{"jsonrpc":"2.0", "Error": {"code": 403, "message": "Invalid token."}}`)) - return - } - - if handler != nil { - handler(rw, req) - } else { - _, _ = rw.Write([]byte(`{"jsonrpc":"2.0"}`)) - } - }) - +func setupClient(server *httptest.Server) (*Client, error) { client := NewClient("secret") client.apiEndpoint = server.URL + client.HTTPClient = server.Client() - return client + return client, nil } func TestClient_AddRecord(t *testing.T) { - client := setupTest(t, func(rw http.ResponseWriter, req *http.Request) { - apiReq := struct { - Method string `json:"method"` - Params Record `json:"params"` - }{} - - err := json.NewDecoder(req.Body).Decode(&apiReq) - if err != nil { - http.Error(rw, "failed to marshal test request body", http.StatusInternalServerError) - return - } - - apiReq.Params.ID = "123" - - resp := map[string]any{ - "jsonrpc": "2.0", - "id": "897", - "result": apiReq.Params, - } - - err = json.NewEncoder(rw).Encode(resp) - if err != nil { - http.Error(rw, "failed to marshal test response", http.StatusInternalServerError) - return - } - }) + client := servermock.NewBuilder[*Client](setupClient, + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("Njalla secret"), + ). + Route("POST /", + servermock.ResponseFromFixture("add_record.json"), + servermock.CheckRequestJSONBodyFromFile("add_record-request.json")). + Build(t) record := Record{ Content: "foobar", @@ -94,7 +50,13 @@ func TestClient_AddRecord(t *testing.T) { } func TestClient_AddRecord_error(t *testing.T) { - client := setupTest(t, nil) + client := servermock.NewBuilder[*Client](setupClient, + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("Njalla invalid"), + ). + Route("POST /", servermock.ResponseFromFixture("auth_error.json")). + Build(t) + client.token = "invalid" record := Record{ @@ -106,55 +68,20 @@ func TestClient_AddRecord_error(t *testing.T) { } result, err := client.AddRecord(t.Context(), record) - require.Error(t, err) + require.EqualError(t, err, "code: 403, message: Invalid token.") assert.Nil(t, result) } func TestClient_ListRecords(t *testing.T) { - client := setupTest(t, func(rw http.ResponseWriter, req *http.Request) { - apiReq := struct { - Method string `json:"method"` - Params Record `json:"params"` - }{} - - err := json.NewDecoder(req.Body).Decode(&apiReq) - if err != nil { - http.Error(rw, "failed to marshal test request body", http.StatusInternalServerError) - return - } - - resp := map[string]any{ - "jsonrpc": "2.0", - "id": "897", - "result": Records{ - Records: []Record{ - { - ID: "1", - Domain: apiReq.Params.Domain, - Content: "test", - Name: "test01", - TTL: 300, - Type: "TXT", - }, - { - ID: "2", - Domain: apiReq.Params.Domain, - Content: "txtTxt", - Name: "test02", - TTL: 120, - Type: "TXT", - }, - }, - }, - } - - err = json.NewEncoder(rw).Encode(resp) - if err != nil { - http.Error(rw, "failed to marshal test response", http.StatusInternalServerError) - return - } - }) + client := servermock.NewBuilder[*Client](setupClient, + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("Njalla secret"), + ). + Route("POST /", + servermock.ResponseFromFixture("list_records.json"), + servermock.CheckRequestJSONBodyFromFile("list_records-request.json")). + Build(t) records, err := client.ListRecords(t.Context(), "example.com") require.NoError(t, err) @@ -182,49 +109,43 @@ func TestClient_ListRecords(t *testing.T) { } func TestClient_ListRecords_error(t *testing.T) { - client := setupTest(t, nil) + client := servermock.NewBuilder[*Client](setupClient, + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("Njalla invalid"), + ). + Route("POST /", servermock.ResponseFromFixture("auth_error.json")). + Build(t) + client.token = "invalid" records, err := client.ListRecords(t.Context(), "example.com") - require.Error(t, err) + require.EqualError(t, err, "code: 403, message: Invalid token.") assert.Empty(t, records) } func TestClient_RemoveRecord(t *testing.T) { - client := setupTest(t, func(rw http.ResponseWriter, req *http.Request) { - apiReq := struct { - Method string `json:"method"` - Params Record `json:"params"` - }{} - - err := json.NewDecoder(req.Body).Decode(&apiReq) - if err != nil { - http.Error(rw, "failed to marshal test request body", http.StatusInternalServerError) - return - } - - if apiReq.Params.ID == "" { - _, _ = rw.Write([]byte(`{"jsonrpc":"2.0", "Error": {"code": 400, "message": ""missing ID"}}`)) - return - } - - if apiReq.Params.Domain == "" { - _, _ = rw.Write([]byte(`{"jsonrpc":"2.0", "Error": {"code": 400, "message": ""missing domain"}}`)) - return - } - - _, _ = rw.Write([]byte(`{"jsonrpc":"2.0"}`)) - }) + client := servermock.NewBuilder[*Client](setupClient, + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("Njalla secret"), + ). + Route("POST /", + servermock.RawStringResponse(`{"jsonrpc":"2.0"}`), + servermock.CheckRequestJSONBodyFromFile("remove_record-request.json")). + Build(t) err := client.RemoveRecord(t.Context(), "123", "example.com") require.NoError(t, err) } func TestClient_RemoveRecord_error(t *testing.T) { - client := setupTest(t, nil) - client.token = "invalid" + client := servermock.NewBuilder[*Client](setupClient, + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("Njalla secret"), + ). + Route("POST /", servermock.ResponseFromFixture("remove_record_error_missing_domain.json")). + Build(t) err := client.RemoveRecord(t.Context(), "123", "example.com") - require.Error(t, err) + require.EqualError(t, err, "code: 400, message: missing domain") } diff --git a/providers/dns/njalla/internal/fixtures/add_record-request.json b/providers/dns/njalla/internal/fixtures/add_record-request.json new file mode 100644 index 000000000..a85e1aaf1 --- /dev/null +++ b/providers/dns/njalla/internal/fixtures/add_record-request.json @@ -0,0 +1,10 @@ +{ + "method": "add-record", + "params": { + "content": "foobar", + "domain": "test", + "name": "example.com", + "ttl": 300, + "type": "TXT" + } +} diff --git a/providers/dns/njalla/internal/fixtures/add_record.json b/providers/dns/njalla/internal/fixtures/add_record.json new file mode 100644 index 000000000..a537762bf --- /dev/null +++ b/providers/dns/njalla/internal/fixtures/add_record.json @@ -0,0 +1,12 @@ +{ + "id": "897", + "jsonrpc": "2.0", + "result": { + "id": "123", + "content": "foobar", + "domain": "test", + "name": "example.com", + "ttl": 300, + "type": "TXT" + } +} diff --git a/providers/dns/njalla/internal/fixtures/auth_error.json b/providers/dns/njalla/internal/fixtures/auth_error.json new file mode 100644 index 000000000..e9d07be51 --- /dev/null +++ b/providers/dns/njalla/internal/fixtures/auth_error.json @@ -0,0 +1,7 @@ +{ + "jsonrpc": "2.0", + "Error": { + "code": 403, + "message": "Invalid token." + } +} diff --git a/providers/dns/njalla/internal/fixtures/list_records-request.json b/providers/dns/njalla/internal/fixtures/list_records-request.json new file mode 100644 index 000000000..ebe5ccf72 --- /dev/null +++ b/providers/dns/njalla/internal/fixtures/list_records-request.json @@ -0,0 +1,6 @@ +{ + "method": "list-records", + "params": { + "domain": "example.com" + } +} diff --git a/providers/dns/njalla/internal/fixtures/list_records.json b/providers/dns/njalla/internal/fixtures/list_records.json new file mode 100644 index 000000000..a280a4b3f --- /dev/null +++ b/providers/dns/njalla/internal/fixtures/list_records.json @@ -0,0 +1,24 @@ +{ + "id": "897", + "jsonrpc": "2.0", + "result": { + "records": [ + { + "id": "1", + "content": "test", + "domain": "example.com", + "name": "test01", + "ttl": 300, + "type": "TXT" + }, + { + "id": "2", + "content": "txtTxt", + "domain": "example.com", + "name": "test02", + "ttl": 120, + "type": "TXT" + } + ] + } +} diff --git a/providers/dns/njalla/internal/fixtures/remove_record-request.json b/providers/dns/njalla/internal/fixtures/remove_record-request.json new file mode 100644 index 000000000..c96e94423 --- /dev/null +++ b/providers/dns/njalla/internal/fixtures/remove_record-request.json @@ -0,0 +1,7 @@ +{ + "method": "remove-record", + "params": { + "id": "123", + "domain": "example.com" + } +} diff --git a/providers/dns/njalla/internal/fixtures/remove_record_error_missing_domain.json b/providers/dns/njalla/internal/fixtures/remove_record_error_missing_domain.json new file mode 100644 index 000000000..f65d254d0 --- /dev/null +++ b/providers/dns/njalla/internal/fixtures/remove_record_error_missing_domain.json @@ -0,0 +1,7 @@ +{ + "jsonrpc": "2.0", + "Error": { + "code": 400, + "message": "missing domain" + } +} diff --git a/providers/dns/njalla/internal/fixtures/remove_record_error_missing_id.json b/providers/dns/njalla/internal/fixtures/remove_record_error_missing_id.json new file mode 100644 index 000000000..544cd4d1c --- /dev/null +++ b/providers/dns/njalla/internal/fixtures/remove_record_error_missing_id.json @@ -0,0 +1,7 @@ +{ + "jsonrpc": "2.0", + "Error": { + "code": 400, + "message": "missing ID" + } +} diff --git a/providers/dns/otc/internal/client_test.go b/providers/dns/otc/internal/client_test.go new file mode 100644 index 000000000..ea3835a56 --- /dev/null +++ b/providers/dns/otc/internal/client_test.go @@ -0,0 +1,109 @@ +package internal + +import ( + "context" + "net/http/httptest" + "net/url" + "strconv" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder( + func(server *httptest.Server) (*Client, error) { + client := NewClient("user", "secret", "example.com", "test") + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) + + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(), + ) +} + +func TestClient_GetZoneID(t *testing.T) { + client := mockBuilder(). + Route("GET /zones", + servermock.ResponseFromFixture("zones_GET.json"), + servermock.CheckQueryParameter().Strict(). + With("name", "example.com.")). + Build(t) + + zoneID, err := client.GetZoneID(context.Background(), "example.com.") + require.NoError(t, err) + + assert.Equal(t, "123123", zoneID) +} + +func TestClient_GetZoneID_error(t *testing.T) { + client := mockBuilder(). + Route("GET /zones", + servermock.ResponseFromFixture("zones_GET_empty.json"), + servermock.CheckQueryParameter().Strict(). + With("name", "example.com.")). + Build(t) + + _, err := client.GetZoneID(context.Background(), "example.com.") + require.EqualError(t, err, "zone example.com. not found") +} + +func TestClient_GetRecordSetID(t *testing.T) { + client := mockBuilder(). + Route("GET /zones/123123/recordsets", + servermock.ResponseFromFixture("zones-recordsets_GET.json"), + servermock.CheckQueryParameter().Strict(). + With("name", "example.com."). + With("type", "TXT"), + ). + Build(t) + + recordSetID, err := client.GetRecordSetID(context.Background(), "123123", "example.com.") + require.NoError(t, err) + + assert.Equal(t, "321321", recordSetID) +} + +func TestClient_GetRecordSetID_error(t *testing.T) { + client := mockBuilder(). + Route("GET /zones/123123/recordsets", + servermock.ResponseFromFixture("zones-recordsets_GET_empty.json"), + servermock.CheckQueryParameter().Strict(). + With("name", "example.com."). + With("type", "TXT"), + ). + Build(t) + + _, err := client.GetRecordSetID(context.Background(), "123123", "example.com.") + require.EqualError(t, err, "record not found") +} + +func TestClient_CreateRecordSet(t *testing.T) { + client := mockBuilder(). + Route("POST /zones/123123/recordsets", + servermock.ResponseFromFixture("zones-recordsets_POST.json")). + Build(t) + + rs := RecordSets{ + Name: "_acme-challenge.example.com.", + Description: "Added TXT record for ACME dns-01 challenge using lego client", + Type: "TXT", + TTL: 300, + Records: []string{strconv.Quote("w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI")}, + } + err := client.CreateRecordSet(context.Background(), "123123", rs) + require.NoError(t, err) +} + +func TestClient_DeleteRecordSet(t *testing.T) { + client := mockBuilder(). + Route("DELETE /zones/123123/recordsets/321321", + servermock.ResponseFromFixture("zones-recordsets_DELETE.json")). + Build(t) + + err := client.DeleteRecordSet(context.Background(), "123123", "321321") + require.NoError(t, err) +} diff --git a/providers/dns/otc/internal/identity_test.go b/providers/dns/otc/internal/identity_test.go index c8bda7027..4dce72afc 100644 --- a/providers/dns/otc/internal/identity_test.go +++ b/providers/dns/otc/internal/identity_test.go @@ -1,24 +1,36 @@ package internal import ( + "net/http/httptest" "net/url" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestClient_Login(t *testing.T) { - mock := NewDNSServerMock(t) - mock.HandleAuthSuccessfully() + var serverURL *url.URL - client := NewClient("user", "secret", "example.com", "test") - client.IdentityEndpoint, _ = url.JoinPath(mock.GetServerURL(), "/v3/auth/token") + client := servermock.NewBuilder( + func(server *httptest.Server) (*Client, error) { + client := NewClient("user", "secret", "example.com", "test") + client.HTTPClient = server.Client() + client.IdentityEndpoint = server.URL + "/v3/auth/token" + + serverURL, _ = url.Parse(server.URL) + + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(), + ). + Route("POST /v3/auth/token", IdentityHandlerMock()). + Build(t) err := client.Login(t.Context()) require.NoError(t, err) - serverURL, _ := url.Parse(mock.GetServerURL()) assert.Equal(t, serverURL.JoinPath("v2").String(), client.baseURL.String()) assert.Equal(t, fakeOTCToken, client.token) } diff --git a/providers/dns/otc/internal/mock.go b/providers/dns/otc/internal/mock.go index 2ed7f84de..46da61e4c 100644 --- a/providers/dns/otc/internal/mock.go +++ b/providers/dns/otc/internal/mock.go @@ -2,62 +2,13 @@ package internal import ( "fmt" - "io" "net/http" - "net/http/httptest" - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) const fakeOTCToken = "62244bc21da68d03ebac94e6636ff01f" -func writeFixture(rw http.ResponseWriter, filename string) { - file, err := os.Open(filepath.Join("internal", "fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, _ = io.Copy(rw, file) -} - -// DNSServerMock mock. -type DNSServerMock struct { - t *testing.T - server *httptest.Server - mux *http.ServeMux -} - -// NewDNSServerMock create a new DNSServerMock. -func NewDNSServerMock(t *testing.T) *DNSServerMock { - t.Helper() - - mux := http.NewServeMux() - - return &DNSServerMock{ - t: t, - server: httptest.NewServer(mux), - mux: mux, - } -} - -func (m *DNSServerMock) GetServerURL() string { - return m.server.URL -} - -// ShutdownServer creates the mock server. -func (m *DNSServerMock) ShutdownServer() { - m.server.Close() -} - -// HandleAuthSuccessfully Handle auth successfully. -func (m *DNSServerMock) HandleAuthSuccessfully() { - m.mux.HandleFunc("/v3/auth/token", func(w http.ResponseWriter, _ *http.Request) { +func IdentityHandlerMock() http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { w.Header().Set("X-Subject-Token", fakeOTCToken) _, _ = fmt.Fprintf(w, `{ @@ -69,7 +20,7 @@ func (m *DNSServerMock) HandleAuthSuccessfully() { "name": "", "endpoints": [ { - "url": "%s", + "url": "http://%s", "region": "eu-de", "region_id": "eu-de", "interface": "public", @@ -78,87 +29,6 @@ func (m *DNSServerMock) HandleAuthSuccessfully() { ] } ] - }}`, m.server.URL) - }) -} - -// HandleListZonesSuccessfully Handle list zones successfully. -func (m *DNSServerMock) HandleListZonesSuccessfully() { - m.mux.HandleFunc("/v2/zones", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(m.t, http.MethodGet, r.Method) - assert.Equal(m.t, "/v2/zones", r.URL.Path) - assert.Equal(m.t, "name=example.com.", r.URL.RawQuery) - assert.Equal(m.t, "application/json", r.Header.Get("Accept")) - - writeFixture(w, "zones_GET.json") - }) -} - -// HandleListZonesEmpty Handle list zones empty. -func (m *DNSServerMock) HandleListZonesEmpty() { - m.mux.HandleFunc("/v2/zones", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(m.t, http.MethodGet, r.Method) - assert.Equal(m.t, "/v2/zones", r.URL.Path) - assert.Equal(m.t, "name=example.com.", r.URL.RawQuery) - assert.Equal(m.t, "application/json", r.Header.Get("Accept")) - - writeFixture(w, "zones_GET_empty.json") - }) -} - -// HandleDeleteRecordsetsSuccessfully Handle delete recordsets successfully. -func (m *DNSServerMock) HandleDeleteRecordsetsSuccessfully() { - m.mux.HandleFunc("/v2/zones/123123/recordsets/321321", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(m.t, http.MethodDelete, r.Method) - assert.Equal(m.t, "/v2/zones/123123/recordsets/321321", r.URL.Path) - assert.Equal(m.t, "application/json", r.Header.Get("Accept")) - - writeFixture(w, "zones-recordsets_DELETE.json") - }) -} - -// HandleListRecordsetsEmpty Handle list recordsets empty. -func (m *DNSServerMock) HandleListRecordsetsEmpty() { - m.mux.HandleFunc("/v2/zones/123123/recordsets", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(m.t, "/v2/zones/123123/recordsets", r.URL.Path) - assert.Equal(m.t, "name=_acme-challenge.example.com.&type=TXT", r.URL.RawQuery) - - writeFixture(w, "zones-recordsets_GET_empty.json") - }) -} - -// HandleListRecordsetsSuccessfully Handle list recordsets successfully. -func (m *DNSServerMock) HandleListRecordsetsSuccessfully() { - m.mux.HandleFunc("/v2/zones/123123/recordsets", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(m.t, "application/json", r.Header.Get("Accept")) - - if r.Method == http.MethodGet { - assert.Equal(m.t, "/v2/zones/123123/recordsets", r.URL.Path) - assert.Equal(m.t, "name=_acme-challenge.example.com.&type=TXT", r.URL.RawQuery) - - writeFixture(w, "zones-recordsets_GET.json") - return - } - - if r.Method == http.MethodPost { - assert.Equal(m.t, "application/json", r.Header.Get("Content-Type")) - - raw, err := io.ReadAll(r.Body) - require.NoError(m.t, err) - exceptedString := `{ - "name": "_acme-challenge.example.com.", - "description": "Added TXT record for ACME dns-01 challenge using lego client", - "type": "TXT", - "ttl": 300, - "records": ["\"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI\""] - }` - - assert.JSONEq(m.t, exceptedString, string(raw)) - - writeFixture(w, "zones-recordsets_POST.json") - return - } - - http.Error(w, fmt.Sprintf("Expected method to be 'GET' or 'POST' but got '%s'", r.Method), http.StatusBadRequest) - }) + }}`, req.Context().Value(http.LocalAddrContextKey)) + } } diff --git a/providers/dns/otc/otc_test.go b/providers/dns/otc/otc_test.go index 54907b69e..1e53f31cc 100644 --- a/providers/dns/otc/otc_test.go +++ b/providers/dns/otc/otc_test.go @@ -2,129 +2,296 @@ package otc import ( "fmt" - "os" + "net/http/httptest" + "path" "testing" + "time" "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/go-acme/lego/v4/providers/dns/otc/internal" - "github.com/stretchr/testify/suite" + "github.com/stretchr/testify/require" ) -type OTCSuite struct { - suite.Suite +const envDomain = envNamespace + "DOMAIN" - mock *internal.DNSServerMock - envTest *tester.EnvTest +var envTest = tester.NewEnvTest( + EnvDomainName, + EnvUserName, + EnvPassword, + EnvProjectName, + EnvIdentityEndpoint). + WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvDomainName: "example.com", + EnvUserName: "user", + EnvPassword: "secret", + EnvProjectName: "test", + }, + }, + { + desc: "missing credentials", + envVars: map[string]string{ + EnvDomainName: "", + EnvUserName: "", + EnvPassword: "", + EnvProjectName: "", + }, + expected: "otc: some credentials information are missing: OTC_DOMAIN_NAME,OTC_USER_NAME,OTC_PASSWORD,OTC_PROJECT_NAME", + }, + { + desc: "missing domain name", + envVars: map[string]string{ + EnvDomainName: "", + EnvUserName: "user", + EnvPassword: "secret", + EnvProjectName: "test", + }, + expected: "otc: some credentials information are missing: OTC_DOMAIN_NAME", + }, + { + desc: "missing username", + envVars: map[string]string{ + EnvDomainName: "example.com", + EnvUserName: "", + EnvPassword: "secret", + EnvProjectName: "test", + }, + expected: "otc: some credentials information are missing: OTC_USER_NAME", + }, + { + desc: "missing password", + envVars: map[string]string{ + EnvDomainName: "example.com", + EnvUserName: "user", + EnvPassword: "", + EnvProjectName: "test", + }, + expected: "otc: some credentials information are missing: OTC_PASSWORD", + }, + { + desc: "missing project name", + envVars: map[string]string{ + EnvDomainName: "example.com", + EnvUserName: "user", + EnvPassword: "secret", + EnvProjectName: "", + }, + expected: "otc: some credentials information are missing: OTC_PROJECT_NAME", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + } else { + require.EqualError(t, err, test.expected) + } + }) + } } -func (s *OTCSuite) SetupTest() { - s.mock = internal.NewDNSServerMock(s.T()) - s.mock.HandleAuthSuccessfully() - s.envTest = tester.NewEnvTest( - EnvDomainName, - EnvUserName, - EnvPassword, - EnvProjectName, - EnvIdentityEndpoint, - ) +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + domainName string + projectName string + username string + password string + expected string + }{ + { + desc: "success", + domainName: "example.com", + projectName: "test", + username: "user", + password: "secret", + }, + { + desc: "missing credentials", + expected: "otc: credentials missing", + }, + { + desc: "missing domain name", + domainName: "", + projectName: "test", + username: "user", + password: "secret", + expected: "otc: credentials missing", + }, + { + desc: "missing project name", + domainName: "example.com", + projectName: "", + username: "user", + password: "secret", + expected: "otc: credentials missing", + }, + { + desc: "missing username", + domainName: "example.com", + projectName: "test", + username: "", + password: "secret", + expected: "otc: credentials missing", + }, + { + desc: "missing password ", + domainName: "example.com", + projectName: "test", + username: "user", + password: "", + expected: "otc: credentials missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.DomainName = test.domainName + config.ProjectName = test.projectName + config.UserName = test.username + config.Password = test.password + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + } else { + require.EqualError(t, err, test.expected) + } + }) + } } -func (s *OTCSuite) TearDownTest() { - s.envTest.RestoreEnv() - s.mock.ShutdownServer() -} - -func TestTestSuite(t *testing.T) { - suite.Run(t, new(OTCSuite)) -} - -func (s *OTCSuite) createDNSProvider() (*DNSProvider, error) { - config := NewDefaultConfig() - config.UserName = "UserName" - config.Password = "Password" - config.DomainName = "DomainName" - config.ProjectName = "ProjectName" - config.IdentityEndpoint = fmt.Sprintf("%s/v3/auth/token", s.mock.GetServerURL()) - - return NewDNSProviderConfig(config) -} - -func (s *OTCSuite) TestLoginEnv() { - s.envTest.ClearEnv() - - s.envTest.Apply(map[string]string{ - EnvDomainName: "unittest1", - EnvUserName: "unittest2", - EnvPassword: "unittest3", - EnvProjectName: "unittest4", - EnvIdentityEndpoint: "unittest5", - }) +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + envTest.RestoreEnv() provider, err := NewDNSProvider() - s.Require().NoError(err) + require.NoError(t, err) - s.Equal("unittest1", provider.config.DomainName) - s.Equal("unittest2", provider.config.UserName) - s.Equal("unittest3", provider.config.Password) - s.Equal("unittest4", provider.config.ProjectName) - s.Equal("unittest5", provider.config.IdentityEndpoint) - - os.Setenv(EnvIdentityEndpoint, "") - - provider, err = NewDNSProvider() - s.Require().NoError(err) - - s.Equal("https://iam.eu-de.otc.t-systems.com:443/v3/auth/tokens", provider.config.IdentityEndpoint) + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) } -func (s *OTCSuite) TestLoginEnvEmpty() { - s.envTest.ClearEnv() +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } - _, err := NewDNSProvider() - s.EqualError(err, "otc: some credentials information are missing: OTC_DOMAIN_NAME,OTC_USER_NAME,OTC_PASSWORD,OTC_PROJECT_NAME") + envTest.RestoreEnv() + provider, err := NewDNSProvider() + require.NoError(t, err) + + time.Sleep(1 * time.Second) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) } -func (s *OTCSuite) TestDNSProvider_Present() { - s.mock.HandleListZonesSuccessfully() - s.mock.HandleListRecordsetsSuccessfully() +func TestDNSProvider_Present(t *testing.T) { + provider := mockBuilder(). + Route("GET /v2/zones", + responseFromFixture("zones_GET.json"), + servermock.CheckQueryParameter().Strict(). + With("name", "example.com.")). + Route("/", servermock.DumpRequest()). + Build(t) - provider, err := s.createDNSProvider() - s.Require().NoError(err) - - err = provider.Present("example.com", "", "foobar") - s.Require().NoError(err) + err := provider.Present("example.com", "", "123d==") + require.NoError(t, err) } -func (s *OTCSuite) TestDNSProvider_Present_EmptyZone() { - s.mock.HandleListZonesEmpty() - s.mock.HandleListRecordsetsSuccessfully() +func TestDNSProvider_Present_emptyZone(t *testing.T) { + provider := mockBuilder(). + Route("GET /v2/zones", + responseFromFixture("zones_GET_empty.json"), + servermock.CheckQueryParameter().Strict(). + With("name", "example.com.")). + Route("/", servermock.DumpRequest()). + Build(t) - provider, err := s.createDNSProvider() - s.Require().NoError(err) - - err = provider.Present("example.com", "", "foobar") - s.Error(err) + err := provider.Present("example.com", "", "123d==") + require.EqualError(t, err, "otc: unable to get zone: zone example.com. not found") } -func (s *OTCSuite) TestDNSProvider_CleanUp() { - s.mock.HandleListZonesSuccessfully() - s.mock.HandleListRecordsetsSuccessfully() - s.mock.HandleDeleteRecordsetsSuccessfully() +func TestDNSProvider_Cleanup(t *testing.T) { + provider := mockBuilder(). + Route("GET /v2/zones", + responseFromFixture("zones_GET.json"), + servermock.CheckQueryParameter().Strict(). + With("name", "example.com.")). + Route("GET /v2/zones/123123/recordsets", + responseFromFixture("zones-recordsets_GET.json"), + servermock.CheckQueryParameter().Strict(). + With("name", "_acme-challenge.example.com."). + With("type", "TXT")). + Route("DELETE /v2/zones/123123/recordsets/321321", + responseFromFixture("zones-recordsets_DELETE.json")). + Build(t) - provider, err := s.createDNSProvider() - s.Require().NoError(err) - - err = provider.CleanUp("example.com", "", "foobar") - s.Require().NoError(err) + err := provider.CleanUp("example.com", "", "123d==") + require.NoError(t, err) } -func (s *OTCSuite) TestDNSProvider_CleanUp_EmptyRecordset() { - s.mock.HandleListZonesSuccessfully() - s.mock.HandleListRecordsetsEmpty() +func TestDNSProvider_Cleanup_emptyRecordset(t *testing.T) { + provider := mockBuilder(). + Route("GET /v2/zones", + responseFromFixture("zones_GET.json"), + servermock.CheckQueryParameter().Strict(). + With("name", "example.com.")). + Route("GET /v2/zones/123123/recordsets", + responseFromFixture("zones-recordsets_GET_empty.json"), + servermock.CheckQueryParameter().Strict(). + With("name", "_acme-challenge.example.com."). + With("type", "TXT")). + Build(t) - provider, err := s.createDNSProvider() - s.Require().NoError(err) - - err = provider.CleanUp("example.com", "", "foobar") - s.Require().Error(err) + err := provider.CleanUp("example.com", "", "123d==") + require.EqualError(t, err, "otc: unable to get record _acme-challenge.example.com. for zone example.com: record not found") +} + +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.UserName = "user" + config.Password = "secret" + config.DomainName = "example.com" + config.ProjectName = "test" + config.IdentityEndpoint = fmt.Sprintf("%s/v3/auth/token", server.URL) + + return NewDNSProviderConfig(config) + }, + servermock.CheckHeader().WithJSONHeaders(), + ). + Route("POST /v3/auth/token", internal.IdentityHandlerMock()) +} + +func responseFromFixture(filename string) *servermock.ResponseFromFileHandler { + return servermock.ResponseFromFile(path.Join("internal", "fixtures", filename)) } diff --git a/providers/dns/pdns/internal/client.go b/providers/dns/pdns/internal/client.go index bc525c578..f6b55d5de 100644 --- a/providers/dns/pdns/internal/client.go +++ b/providers/dns/pdns/internal/client.go @@ -18,6 +18,9 @@ import ( "github.com/miekg/dns" ) +// APIKeyHeader API key header. +const APIKeyHeader = "X-Api-Key" + // Client the PowerDNS API client. type Client struct { serverName string @@ -163,7 +166,7 @@ func (c *Client) joinPath(elem ...string) *url.URL { } func (c *Client) do(req *http.Request) (json.RawMessage, error) { - req.Header.Set("X-API-Key", c.apiKey) + req.Header.Set(APIKeyHeader, c.apiKey) resp, err := c.HTTPClient.Do(req) if err != nil { diff --git a/providers/dns/pdns/internal/client_test.go b/providers/dns/pdns/internal/client_test.go index d3919ace3..6d1c48852 100644 --- a/providers/dns/pdns/internal/client_test.go +++ b/providers/dns/pdns/internal/client_test.go @@ -1,65 +1,27 @@ package internal import ( - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, method, pattern string, status int, file string) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + serverURL, _ := url.Parse(server.URL) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) + client := NewClient(serverURL, "server", 0, "secret") + client.HTTPClient = server.Client() - mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusBadRequest) - return - } - - apiKey := req.Header.Get("X-API-Key") - if apiKey != "secret" { - http.Error(rw, fmt.Sprintf("invalid credentials: %s", apiKey), http.StatusBadRequest) - return - } - - if file == "" { - rw.WriteHeader(status) - return - } - - open, err := os.Open(filepath.Join("fixtures", file)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = open.Close() }() - - rw.WriteHeader(status) - _, err = io.Copy(rw, open) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - serverURL, _ := url.Parse(server.URL) - - client := NewClient(serverURL, "server", 0, "secret") - client.HTTPClient = server.Client() - - return client + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders().With(APIKeyHeader, "secret")) } func TestClient_joinPath(t *testing.T) { @@ -159,7 +121,11 @@ func TestClient_joinPath(t *testing.T) { } func TestClient_GetHostedZone(t *testing.T) { - client := setupTest(t, http.MethodGet, "/api/v1/servers/server/zones/example.org.", http.StatusOK, "zone.json") + client := mockBuilder(). + Route("GET /api/v1/servers/server/zones/example.org.", + servermock.ResponseFromFixture("zone.json")). + Build(t) + client.apiVersion = 1 zone, err := client.GetHostedZone(t.Context(), "example.org.") @@ -202,7 +168,12 @@ func TestClient_GetHostedZone(t *testing.T) { } func TestClient_GetHostedZone_error(t *testing.T) { - client := setupTest(t, http.MethodGet, "/api/v1/servers/server/zones/example.org.", http.StatusUnprocessableEntity, "error.json") + client := mockBuilder(). + Route("GET /api/v1/servers/server/zones/example.org.", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnprocessableEntity)). + Build(t) + client.apiVersion = 1 _, err := client.GetHostedZone(t.Context(), "example.org.") @@ -210,7 +181,11 @@ func TestClient_GetHostedZone_error(t *testing.T) { } func TestClient_GetHostedZone_v0(t *testing.T) { - client := setupTest(t, http.MethodGet, "/servers/server/zones/example.org.", http.StatusOK, "zone.json") + client := mockBuilder(). + Route("GET /servers/server/zones/example.org.", + servermock.ResponseFromFixture("zone.json")). + Build(t) + client.apiVersion = 0 zone, err := client.GetHostedZone(t.Context(), "example.org.") @@ -253,7 +228,12 @@ func TestClient_GetHostedZone_v0(t *testing.T) { } func TestClient_UpdateRecords(t *testing.T) { - client := setupTest(t, http.MethodPatch, "/api/v1/servers/localhost/zones/example.org.", http.StatusOK, "zone.json") + client := mockBuilder(). + Route("PATCH /api/v1/servers/localhost/zones/example.org.", + servermock.ResponseFromFixture("zone.json"), + servermock.CheckRequestJSONBodyFromFile("zone-request.json")). + Build(t) + client.apiVersion = 1 client.serverName = "localhost" @@ -283,7 +263,12 @@ func TestClient_UpdateRecords(t *testing.T) { } func TestClient_UpdateRecords_NonRootApi(t *testing.T) { - client := setupTest(t, http.MethodPatch, "/some/path/api/v1/servers/localhost/zones/example.org.", http.StatusOK, "zone.json") + client := mockBuilder(). + Route("PATCH /some/path/api/v1/servers/localhost/zones/example.org.", + servermock.ResponseFromFixture("zone.json"), + servermock.CheckRequestJSONBodyFromFile("zone-request.json")). + Build(t) + client.Host = client.Host.JoinPath("some", "path") client.apiVersion = 1 client.serverName = "localhost" @@ -314,7 +299,12 @@ func TestClient_UpdateRecords_NonRootApi(t *testing.T) { } func TestClient_UpdateRecords_v0(t *testing.T) { - client := setupTest(t, http.MethodPatch, "/servers/localhost/zones/example.org.", http.StatusOK, "zone.json") + client := mockBuilder(). + Route("PATCH /servers/localhost/zones/example.org.", + servermock.ResponseFromFixture("zone.json"), + servermock.CheckRequestJSONBodyFromFile("zone-request.json")). + Build(t) + client.apiVersion = 0 client.serverName = "localhost" @@ -344,7 +334,10 @@ func TestClient_UpdateRecords_v0(t *testing.T) { } func TestClient_Notify(t *testing.T) { - client := setupTest(t, http.MethodPut, "/api/v1/servers/localhost/zones/example.org./notify", http.StatusOK, "") + client := mockBuilder(). + Route("PUT /api/v1/servers/localhost/zones/example.org./notify", nil). + Build(t) + client.apiVersion = 1 client.serverName = "localhost" @@ -360,7 +353,10 @@ func TestClient_Notify(t *testing.T) { } func TestClient_Notify_NonRootApi(t *testing.T) { - client := setupTest(t, http.MethodPut, "/some/path/api/v1/servers/localhost/zones/example.org./notify", http.StatusOK, "") + client := mockBuilder(). + Route("PUT /some/path/api/v1/servers/localhost/zones/example.org./notify", nil). + Build(t) + client.Host = client.Host.JoinPath("some", "path") client.apiVersion = 1 client.serverName = "localhost" @@ -377,7 +373,10 @@ func TestClient_Notify_NonRootApi(t *testing.T) { } func TestClient_Notify_v0(t *testing.T) { - client := setupTest(t, http.MethodPut, "/api/v1/servers/localhost/zones/example.org./notify", http.StatusOK, "") + client := mockBuilder(). + Route("PUT /some/path/api/v1/servers/localhost/zones/example.org./notify", nil). + Build(t) + client.apiVersion = 0 zone := &HostedZone{ @@ -392,7 +391,10 @@ func TestClient_Notify_v0(t *testing.T) { } func TestClient_getAPIVersion(t *testing.T) { - client := setupTest(t, http.MethodGet, "/api", http.StatusOK, "versions.json") + client := mockBuilder(). + Route("GET /api", + servermock.ResponseFromFixture("versions.json")). + Build(t) version, err := client.getAPIVersion(t.Context()) require.NoError(t, err) diff --git a/providers/dns/pdns/internal/fixtures/zone-request.json b/providers/dns/pdns/internal/fixtures/zone-request.json new file mode 100644 index 000000000..5e4a6d2b9 --- /dev/null +++ b/providers/dns/pdns/internal/fixtures/zone-request.json @@ -0,0 +1,19 @@ +{ + "rrsets": [ + { + "name": "example.org.", + "type": "NS", + "kind": "", + "changetype": "REPLACE", + "records": [ + { + "content": "192.0.2.5", + "disabled": false, + "name": "ns1.example.org.", + "type": "A", + "ttl": 86400 + } + ] + } + ] +} diff --git a/providers/dns/plesk/internal/client_test.go b/providers/dns/plesk/internal/client_test.go index b61bce4c2..14cadd0e0 100644 --- a/providers/dns/plesk/internal/client_test.go +++ b/providers/dns/plesk/internal/client_test.go @@ -1,69 +1,35 @@ package internal import ( - "fmt" - "io" - "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, filename string) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + serverURL, _ := url.Parse(server.URL) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) + client := NewClient(serverURL, "user", "secret") + client.HTTPClient = server.Client() - serverURL, err := url.Parse(server.URL) - require.NoError(t, err) - - client := NewClient(serverURL, "user", "secret") - client.HTTPClient = server.Client() - - mux.HandleFunc("/enterprise/control/agent.php", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - login := req.Header.Get("Http_auth_login") - if login != "user" { - http.Error(rw, fmt.Sprintf("invalid login: %s", login), http.StatusUnauthorized) - return - } - - password := req.Header.Get("Http_auth_passwd") - if password != "secret" { - http.Error(rw, fmt.Sprintf("invalid password: %s", password), http.StatusUnauthorized) - return - } - - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - return client + return client, nil + }, + servermock.CheckHeader().WithContentType("text/xml"). + With("Http_auth_login", "user"). + With("Http_auth_passwd", "secret"), + ) } func TestClient_GetSite(t *testing.T) { - client := setupTest(t, "get-site.xml") + client := mockBuilder(). + Route("POST /enterprise/control/agent.php", servermock.ResponseFromFixture("get-site.xml")). + Build(t) siteID, err := client.GetSite(t.Context(), "example.com") require.NoError(t, err) @@ -72,7 +38,9 @@ func TestClient_GetSite(t *testing.T) { } func TestClient_GetSite_error(t *testing.T) { - client := setupTest(t, "get-site-error.xml") + client := mockBuilder(). + Route("POST /enterprise/control/agent.php", servermock.ResponseFromFixture("get-site-error.xml")). + Build(t) siteID, err := client.GetSite(t.Context(), "example.com") require.Error(t, err) @@ -81,7 +49,9 @@ func TestClient_GetSite_error(t *testing.T) { } func TestClient_GetSite_system_error(t *testing.T) { - client := setupTest(t, "global-error.xml") + client := mockBuilder(). + Route("POST /enterprise/control/agent.php", servermock.ResponseFromFixture("global-error.xml")). + Build(t) siteID, err := client.GetSite(t.Context(), "example.com") require.Error(t, err) @@ -90,7 +60,9 @@ func TestClient_GetSite_system_error(t *testing.T) { } func TestClient_AddRecord(t *testing.T) { - client := setupTest(t, "add-record.xml") + client := mockBuilder(). + Route("POST /enterprise/control/agent.php", servermock.ResponseFromFixture("add-record.xml")). + Build(t) recordID, err := client.AddRecord(t.Context(), 123, "_acme-challenge.example.com", "txtTXTtxt") require.NoError(t, err) @@ -99,7 +71,9 @@ func TestClient_AddRecord(t *testing.T) { } func TestClient_AddRecord_error(t *testing.T) { - client := setupTest(t, "add-record-error.xml") + client := mockBuilder(). + Route("POST /enterprise/control/agent.php", servermock.ResponseFromFixture("add-record-error.xml")). + Build(t) recordID, err := client.AddRecord(t.Context(), 123, "_acme-challenge.example.com", "txtTXTtxt") require.ErrorAs(t, err, new(RecResult)) @@ -108,7 +82,9 @@ func TestClient_AddRecord_error(t *testing.T) { } func TestClient_AddRecord_system_error(t *testing.T) { - client := setupTest(t, "global-error.xml") + client := mockBuilder(). + Route("POST /enterprise/control/agent.php", servermock.ResponseFromFixture("global-error.xml")). + Build(t) recordID, err := client.AddRecord(t.Context(), 123, "_acme-challenge.example.com", "txtTXTtxt") require.ErrorAs(t, err, new(*System)) @@ -117,7 +93,9 @@ func TestClient_AddRecord_system_error(t *testing.T) { } func TestClient_DeleteRecord(t *testing.T) { - client := setupTest(t, "delete-record.xml") + client := mockBuilder(). + Route("POST /enterprise/control/agent.php", servermock.ResponseFromFixture("delete-record.xml")). + Build(t) recordID, err := client.DeleteRecord(t.Context(), 4537) require.NoError(t, err) @@ -126,7 +104,9 @@ func TestClient_DeleteRecord(t *testing.T) { } func TestClient_DeleteRecord_error(t *testing.T) { - client := setupTest(t, "delete-record-error.xml") + client := mockBuilder(). + Route("POST /enterprise/control/agent.php", servermock.ResponseFromFixture("delete-record-error.xml")). + Build(t) recordID, err := client.DeleteRecord(t.Context(), 4537) require.ErrorAs(t, err, new(RecResult)) @@ -135,7 +115,9 @@ func TestClient_DeleteRecord_error(t *testing.T) { } func TestClient_DeleteRecord_system_error(t *testing.T) { - client := setupTest(t, "global-error.xml") + client := mockBuilder(). + Route("POST /enterprise/control/agent.php", servermock.ResponseFromFixture("global-error.xml")). + Build(t) recordID, err := client.DeleteRecord(t.Context(), 4537) require.ErrorAs(t, err, new(*System)) diff --git a/providers/dns/rackspace/fixtures/delete.json b/providers/dns/rackspace/fixtures/delete.json new file mode 100644 index 000000000..7e2f2ac53 --- /dev/null +++ b/providers/dns/rackspace/fixtures/delete.json @@ -0,0 +1,7 @@ +{ + "status": "RUNNING", + "verb": "DELETE", + "jobId": "00000000-0000-0000-0000-0000000000", + "callbackUrl": "https://dns.api.rackspacecloud.com/v1.0/123456/status/00000000-0000-0000-0000-0000000000", + "requestUrl": "https://dns.api.rackspacecloud.com/v1.0/123456/domains/112233/recordsid=TXT-654321" +} diff --git a/providers/dns/rackspace/fixtures/identity.json b/providers/dns/rackspace/fixtures/identity.json new file mode 100644 index 000000000..5a459d13c --- /dev/null +++ b/providers/dns/rackspace/fixtures/identity.json @@ -0,0 +1,31 @@ +{ + "access": { + "token": { + "id": "testToken", + "expires": "1970-01-01T00:00:00.000Z", + "tenant": { + "id": "123456", + "name": "123456" + }, + "RAX-AUTH:authenticatedBy": [ + "APIKEY" + ] + }, + "serviceCatalog": [ + { + "type": "rax:dns", + "endpoints": [ + { + "publicURL": "https://dns.api.rackspacecloud.com/v1.0/123456", + "tenantId": "123456" + } + ], + "name": "cloudDNS" + } + ], + "user": { + "id": "fakeUseID", + "name": "testUser" + } + } +} diff --git a/providers/dns/rackspace/fixtures/record.json b/providers/dns/rackspace/fixtures/record.json new file mode 100644 index 000000000..4d76aa0c8 --- /dev/null +++ b/providers/dns/rackspace/fixtures/record.json @@ -0,0 +1,8 @@ +{ + "request": "{\"records\":[{\"name\":\"_acme-challenge.example.com\",\"type\":\"TXT\",\"data\":\"pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM\",\"ttl\":300}]}", + "status": "RUNNING", + "verb": "POST", + "jobId": "00000000-0000-0000-0000-0000000000", + "callbackUrl": "https://dns.api.rackspacecloud.com/v1.0/123456/status/00000000-0000-0000-0000-0000000000", + "requestUrl": "https://dns.api.rackspacecloud.com/v1.0/123456/domains/112233/records" +} diff --git a/providers/dns/rackspace/fixtures/record_details.json b/providers/dns/rackspace/fixtures/record_details.json new file mode 100644 index 000000000..e53cf1330 --- /dev/null +++ b/providers/dns/rackspace/fixtures/record_details.json @@ -0,0 +1,13 @@ +{ + "records": [ + { + "name": "_acme-challenge.example.com", + "id": "TXT-654321", + "type": "TXT", + "data": "pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM", + "ttl": 300, + "updated": "1970-01-01T00:00:00.000+0000", + "created": "1970-01-01T00:00:00.000+0000" + } + ] +} diff --git a/providers/dns/rackspace/fixtures/zone_details.json b/providers/dns/rackspace/fixtures/zone_details.json new file mode 100644 index 000000000..f68f23aa0 --- /dev/null +++ b/providers/dns/rackspace/fixtures/zone_details.json @@ -0,0 +1,12 @@ +{ + "domains": [ + { + "name": "example.com", + "id": "112233", + "emailAddress": "hostmaster@example.com", + "updated": "1970-01-01T00:00:00.000+0000", + "created": "1970-01-01T00:00:00.000+0000" + } + ], + "totalEntries": 1 +} diff --git a/providers/dns/rackspace/internal/client.go b/providers/dns/rackspace/internal/client.go index de25f8d0e..076409ebd 100644 --- a/providers/dns/rackspace/internal/client.go +++ b/providers/dns/rackspace/internal/client.go @@ -14,6 +14,8 @@ import ( "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) +const AuthToken = "X-Auth-Token" + type Client struct { token string @@ -34,7 +36,7 @@ func NewClient(endpoint, token string) (*Client, error) { }, nil } -// AddRecord Adds one record to a specified domain. +// AddRecord Adds one record to a specified domain. // https://docs.rackspace.com/docs/cloud-dns/v1/api-reference/records#add-records func (c *Client) AddRecord(ctx context.Context, zoneID string, record Record) error { endpoint := c.baseURL.JoinPath("domains", zoneID, "records") @@ -161,7 +163,7 @@ func (c *Client) searchRecords(ctx context.Context, zoneID, recordName, recordTy } func (c *Client) do(req *http.Request, result any) error { - req.Header.Set("X-Auth-Token", c.token) + req.Header.Set(AuthToken, c.token) resp, err := c.HTTPClient.Do(req) if err != nil { diff --git a/providers/dns/rackspace/internal/client_test.go b/providers/dns/rackspace/internal/client_test.go index ce25d107c..c14c4d360 100644 --- a/providers/dns/rackspace/internal/client_test.go +++ b/providers/dns/rackspace/internal/client_test.go @@ -1,78 +1,62 @@ package internal import ( - "fmt" - "io" - "net/http" "net/http/httptest" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, pattern string, handler http.HandlerFunc) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient(server.URL, "secret") + if err != nil { + return nil, err + } - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) + client.HTTPClient = server.Client() - client, err := NewClient(server.URL, "secret") - require.NoError(t, err) - - client.HTTPClient = server.Client() - - mux.HandleFunc(pattern, handler) - - return client -} - -func writeFixtureHandler(method, filename string) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - if req.Header.Get("X-Auth-Token") != "secret" { - http.Error(rw, fmt.Sprintf("invalid token: %q", req.Header.Get("X-Auth-Token")), http.StatusUnauthorized) - return - } - - if filename == "" { - return - } - - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, _ = io.Copy(rw, file) - } + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + With(AuthToken, "secret")) } func TestClient_AddRecord(t *testing.T) { - client := setupTest(t, "/domains/1234/records", writeFixtureHandler(http.MethodPost, "add-records.json")) + client := mockBuilder(). + Route("POST /domains/1234/records", + servermock.ResponseFromFixture("add-records.json"), + servermock.CheckRequestJSONBody(`{"records":[{"name":"exmaple.com","type":"TXT","data":"value1","ttl":120,"id":"abc"}]}`)). + Build(t) - err := client.AddRecord(t.Context(), "1234", Record{}) + record := Record{ + Name: "exmaple.com", + Type: "TXT", + Data: "value1", + TTL: 120, + ID: "abc", + } + + err := client.AddRecord(t.Context(), "1234", record) require.NoError(t, err) } func TestClient_DeleteRecord(t *testing.T) { - client := setupTest(t, "/domains/1234/records", writeFixtureHandler(http.MethodDelete, "")) + client := mockBuilder(). + Route("DELETE /domains/1234/records", nil). + Build(t) err := client.DeleteRecord(t.Context(), "1234", "2725233") require.NoError(t, err) } func TestClient_searchRecords(t *testing.T) { - client := setupTest(t, "/domains/1234/records", writeFixtureHandler(http.MethodGet, "search-records.json")) + client := mockBuilder(). + Route("GET /domains/1234/records", servermock.ResponseFromFixture("search-records.json")). + Build(t) records, err := client.searchRecords(t.Context(), "1234", "2725233", "A") require.NoError(t, err) @@ -93,7 +77,9 @@ func TestClient_searchRecords(t *testing.T) { } func TestClient_listDomainsByName(t *testing.T) { - client := setupTest(t, "/domains", writeFixtureHandler(http.MethodGet, "list-domains-by-name.json")) + client := mockBuilder(). + Route("GET /domains", servermock.ResponseFromFixture("list-domains-by-name.json")). + Build(t) domains, err := client.listDomainsByName(t.Context(), "1234") require.NoError(t, err) diff --git a/providers/dns/rackspace/internal/identity_test.go b/providers/dns/rackspace/internal/identity_test.go index b976fdd2f..44a8d75fc 100644 --- a/providers/dns/rackspace/internal/identity_test.go +++ b/providers/dns/rackspace/internal/identity_test.go @@ -1,48 +1,22 @@ package internal import ( - "fmt" - "io" - "net/http" "net/http/httptest" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func writeIdentityFixtureHandler(method, filename string) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - if filename == "" { - return - } - - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, _ = io.Copy(rw, file) - } +func setupIdentifier(server *httptest.Server) (*Identifier, error) { + return NewIdentifier(server.Client(), server.URL), nil } func TestIdentifier_Login(t *testing.T) { - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - identifier := NewIdentifier(server.Client(), server.URL) - - mux.HandleFunc("/", writeIdentityFixtureHandler(http.MethodPost, "tokens.json")) + identifier := servermock.NewBuilder[*Identifier](setupIdentifier, servermock.CheckHeader().WithJSONHeaders()). + Route("POST /", servermock.ResponseFromFixture("tokens.json")). + Build(t) identity, err := identifier.Login(t.Context(), "user", "secret") require.NoError(t, err) diff --git a/providers/dns/rackspace/rackspace_mock_test.go b/providers/dns/rackspace/rackspace_mock_test.go deleted file mode 100644 index 790d52498..000000000 --- a/providers/dns/rackspace/rackspace_mock_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package rackspace - -const recordDeleteMock = ` -{ - "status": "RUNNING", - "verb": "DELETE", - "jobId": "00000000-0000-0000-0000-0000000000", - "callbackUrl": "https://dns.api.rackspacecloud.com/v1.0/123456/status/00000000-0000-0000-0000-0000000000", - "requestUrl": "https://dns.api.rackspacecloud.com/v1.0/123456/domains/112233/recordsid=TXT-654321" -} -` - -const recordDetailsMock = ` -{ - "records": [ - { - "name": "_acme-challenge.example.com", - "id": "TXT-654321", - "type": "TXT", - "data": "pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM", - "ttl": 300, - "updated": "1970-01-01T00:00:00.000+0000", - "created": "1970-01-01T00:00:00.000+0000" - } - ] -} -` - -const zoneDetailsMock = ` -{ - "domains": [ - { - "name": "example.com", - "id": "112233", - "emailAddress": "hostmaster@example.com", - "updated": "1970-01-01T00:00:00.000+0000", - "created": "1970-01-01T00:00:00.000+0000" - } - ], - "totalEntries": 1 -} -` - -const identityResponseMock = ` -{ - "access": { - "token": { - "id": "testToken", - "expires": "1970-01-01T00:00:00.000Z", - "tenant": { - "id": "123456", - "name": "123456" - }, - "RAX-AUTH:authenticatedBy": [ - "APIKEY" - ] - }, - "serviceCatalog": [ - { - "type": "rax:dns", - "endpoints": [ - { - "publicURL": "https://dns.api.rackspacecloud.com/v1.0/123456", - "tenantId": "123456" - } - ], - "name": "cloudDNS" - } - ], - "user": { - "id": "fakeUseID", - "name": "testUser" - } - } -} -` - -const recordResponseMock = ` -{ - "request": "{\"records\":[{\"name\":\"_acme-challenge.example.com\",\"type\":\"TXT\",\"data\":\"pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM\",\"ttl\":300}]}", - "status": "RUNNING", - "verb": "POST", - "jobId": "00000000-0000-0000-0000-0000000000", - "callbackUrl": "https://dns.api.rackspacecloud.com/v1.0/123456/status/00000000-0000-0000-0000-0000000000", - "requestUrl": "https://dns.api.rackspacecloud.com/v1.0/123456/domains/112233/records" -} -` diff --git a/providers/dns/rackspace/rackspace_test.go b/providers/dns/rackspace/rackspace_test.go index cbc57b472..cefb46134 100644 --- a/providers/dns/rackspace/rackspace_test.go +++ b/providers/dns/rackspace/rackspace_test.go @@ -1,9 +1,7 @@ package rackspace import ( - "bytes" "fmt" - "io" "net/http" "net/http/httptest" "strings" @@ -11,6 +9,7 @@ import ( "time" "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -23,11 +22,7 @@ var envTest = tester.NewEnvTest( WithDomain(envDomain) func TestNewDNSProviderConfig(t *testing.T) { - config := setupTest(t) - - provider, err := NewDNSProviderConfig(config) - require.NoError(t, err) - assert.NotNil(t, provider.config) + provider := mockBuilder().Build(t) assert.Equal(t, "testToken", provider.token, "The token should match") } @@ -38,25 +33,40 @@ func TestNewDNSProviderConfig_MissingCredErr(t *testing.T) { } func TestDNSProvider_Present(t *testing.T) { - config := setupTest(t) + provider := mockBuilder(). + Route("GET /123456/domains", + servermock.ResponseFromFixture("zone_details.json"), + servermock.CheckQueryParameter().Strict(). + With("name", "example.com")). + Route("POST /123456/domains/112233/records", + servermock.ResponseFromFixture("record.json"). + WithStatusCode(http.StatusAccepted), + servermock.CheckRequestJSONBody(`{"records":[{"name":"_acme-challenge.example.com","type":"TXT","data":"pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM","ttl":300}]}`)). + Build(t) - provider, err := NewDNSProviderConfig(config) - - if assert.NoError(t, err) { - err = provider.Present("example.com", "token", "keyAuth") - require.NoError(t, err) - } + err := provider.Present("example.com", "token", "keyAuth") + require.NoError(t, err) } func TestDNSProvider_CleanUp(t *testing.T) { - config := setupTest(t) + provider := mockBuilder(). + Route("GET /123456/domains", + servermock.ResponseFromFixture("zone_details.json"), + servermock.CheckQueryParameter().Strict(). + With("name", "example.com")). + Route("GET /123456/domains/112233/records", + servermock.ResponseFromFixture("record_details.json"), + servermock.CheckQueryParameter().Strict(). + With("type", "TXT"). + With("name", "_acme-challenge.example.com")). + Route("DELETE /123456/domains/112233/records", + servermock.ResponseFromFixture("delete.json"), + servermock.CheckQueryParameter().Strict(). + With("id", "TXT-654321")). + Build(t) - provider, err := NewDNSProviderConfig(config) - - if assert.NoError(t, err) { - err = provider.CleanUp("example.com", "token", "keyAuth") - require.NoError(t, err) - } + err := provider.CleanUp("example.com", "token", "keyAuth") + require.NoError(t, err) } func TestLiveNewDNSProvider_ValidEnv(t *testing.T) { @@ -99,99 +109,59 @@ func TestLiveCleanUp(t *testing.T) { require.NoError(t, err) } -func setupTest(t *testing.T) *Config { - t.Helper() +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.APIUser = "testUser" + config.APIKey = "testKey" + config.HTTPClient = server.Client() + config.BaseURL = server.URL + "/v2.0/tokens" - dnsAPI := httptest.NewServer(dnsHandler()) - t.Cleanup(dnsAPI.Close) + return NewDNSProviderConfig(config) + }, + servermock.CheckHeader().WithJSONHeaders(), + ). + Route("POST /v2.0/tokens", + http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + apiURL := fmt.Sprintf("http://%s/123456", req.Context().Value(http.LocalAddrContextKey)) - identityAPI := httptest.NewServer(identityHandler(dnsAPI.URL + "/123456")) - t.Cleanup(identityAPI.Close) - - config := NewDefaultConfig() - config.APIUser = "testUser" - config.APIKey = "testKey" - config.HTTPClient = identityAPI.Client() - config.BaseURL = identityAPI.URL + "/" - - return config + resp := strings.Replace(` +{ + "access": { + "token": { + "id": "testToken", + "expires": "1970-01-01T00:00:00.000Z", + "tenant": { + "id": "123456", + "name": "123456" + }, + "RAX-AUTH:authenticatedBy": [ + "APIKEY" + ] + }, + "serviceCatalog": [ + { + "type": "rax:dns", + "endpoints": [ + { + "publicURL": "https://dns.api.rackspacecloud.com/v1.0/123456", + "tenantId": "123456" + } + ], + "name": "cloudDNS" + } + ], + "user": { + "id": "fakeUseID", + "name": "testUser" + } + } } +`, "https://dns.api.rackspacecloud.com/v1.0/123456", apiURL, 1) -func identityHandler(dnsEndpoint string) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - reqBody, err := io.ReadAll(r.Body) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - - if string(bytes.TrimSpace(reqBody)) != `{"auth":{"RAX-KSKEY:apiKeyCredentials":{"username":"testUser","apiKey":"testKey"}}}` { - http.Error(w, fmt.Sprintf("invalid body: %s", string(reqBody)), http.StatusBadRequest) - return - } - - resp := strings.Replace(identityResponseMock, "https://dns.api.rackspacecloud.com/v1.0/123456", dnsEndpoint, 1) - w.WriteHeader(http.StatusOK) - _, _ = fmt.Fprint(w, resp) - }) -} - -func dnsHandler() *http.ServeMux { - mux := http.NewServeMux() - - // Used by `getHostedZoneID()` finding `zoneID` "?name=example.com" - mux.HandleFunc("/123456/domains", func(w http.ResponseWriter, r *http.Request) { - if r.URL.Query().Get("name") == "example.com" { - w.WriteHeader(http.StatusOK) - _, _ = fmt.Fprint(w, zoneDetailsMock) - return - } - w.WriteHeader(http.StatusBadRequest) - }) - - mux.HandleFunc("/123456/domains/112233/records", func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - // Used by `Present()` creating the TXT record - case http.MethodPost: - reqBody, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - if string(bytes.TrimSpace(reqBody)) != `{"records":[{"name":"_acme-challenge.example.com","type":"TXT","data":"pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM","ttl":300}]}` { - http.Error(w, fmt.Sprintf("invalid body: %s", string(reqBody)), http.StatusBadRequest) - return - } - - w.WriteHeader(http.StatusAccepted) - _, _ = fmt.Fprint(w, recordResponseMock) - - // Used by `findTxtRecord()` finding `record.ID` "?type=TXT&name=_acme-challenge.example.com" - case http.MethodGet: - if r.URL.Query().Get("type") == "TXT" && r.URL.Query().Get("name") == "_acme-challenge.example.com" { - w.WriteHeader(http.StatusOK) - _, _ = fmt.Fprint(w, recordDetailsMock) - return - } - - w.WriteHeader(http.StatusBadRequest) - return - - // Used by `CleanUp()` deleting the TXT record "?id=445566" - case http.MethodDelete: - if r.URL.Query().Get("id") == "TXT-654321" { - w.WriteHeader(http.StatusOK) - _, _ = fmt.Fprint(w, recordDeleteMock) - return - } - w.WriteHeader(http.StatusBadRequest) - } - }) - - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - http.Error(w, fmt.Sprintf("Not Found for Request: (%+v)", r), http.StatusNotFound) - }) - - return mux + rw.WriteHeader(http.StatusOK) + _, _ = fmt.Fprint(rw, resp) + }), + servermock.CheckRequestJSONBody(`{"auth":{"RAX-KSKEY:apiKeyCredentials":{"username":"testUser","apiKey":"testKey"}}}`)) } diff --git a/providers/dns/rainyun/internal/client_test.go b/providers/dns/rainyun/internal/client_test.go index 1652bba39..8246001af 100644 --- a/providers/dns/rainyun/internal/client_test.go +++ b/providers/dns/rainyun/internal/client_test.go @@ -1,58 +1,39 @@ package internal import ( - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, pattern string, status int, filename string) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient("secret") + if err != nil { + return nil, err + } - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) - mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) { - if filename == "" { - rw.WriteHeader(status) - return - } - - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = file.Close() }() - - rw.WriteHeader(status) - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - client, err := NewClient("secret") - require.NoError(t, err) - - client.HTTPClient = server.Client() - client.baseURL, _ = url.Parse(server.URL) - - return client + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders()) } func TestClient_ListDomains(t *testing.T) { - client := setupTest(t, "GET /domain", http.StatusOK, "domains.json") + client := mockBuilder(). + Route("GET /domain", + servermock.ResponseFromFixture("domains.json"), + servermock.CheckQueryParameter().Strict(). + With("options", `{"columnFilters":{"domains.Domain":""},"sort":[],"page":1,"perPage":100}`)). + Build(t) domains, err := client.ListDomains(t.Context()) require.NoError(t, err) @@ -66,7 +47,11 @@ func TestClient_ListDomains(t *testing.T) { } func TestClient_ListDomains_error(t *testing.T) { - client := setupTest(t, "GET /domain", http.StatusForbidden, "error.json") + client := mockBuilder(). + Route("GET /domain", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusForbidden)). + Build(t) _, err := client.ListDomains(t.Context()) require.Error(t, err) @@ -75,7 +60,13 @@ func TestClient_ListDomains_error(t *testing.T) { } func TestClient_ListRecords(t *testing.T) { - client := setupTest(t, "GET /domain/123/dns", http.StatusOK, "records.json") + client := mockBuilder(). + Route("GET /domain/123/dns", + servermock.ResponseFromFixture("records.json"), + servermock.CheckQueryParameter().Strict(). + With("limit", "100"). + With("page_no", "1")). + Build(t) records, err := client.ListRecords(t.Context(), 123) require.NoError(t, err) @@ -103,7 +94,11 @@ func TestClient_ListRecords(t *testing.T) { } func TestClient_ListRecords_error(t *testing.T) { - client := setupTest(t, "GET /domain/123/dns", http.StatusForbidden, "error.json") + client := mockBuilder(). + Route("GET /domain/123/dns", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusForbidden)). + Build(t) _, err := client.ListRecords(t.Context(), 123) require.Error(t, err) @@ -112,7 +107,9 @@ func TestClient_ListRecords_error(t *testing.T) { } func TestClient_AddRecord(t *testing.T) { - client := setupTest(t, "POST /domain/123/dns", http.StatusOK, "") + client := mockBuilder(). + Route("POST /domain/123/dns", nil). + Build(t) record := Record{ Host: "_acme-challenge.foo.example.com", @@ -127,7 +124,11 @@ func TestClient_AddRecord(t *testing.T) { } func TestClient_AddRecord_error(t *testing.T) { - client := setupTest(t, "POST /domain/123/dns", http.StatusForbidden, "error.json") + client := mockBuilder(). + Route("POST /domain/123/dns", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusForbidden)). + Build(t) record := Record{ Host: "_acme-challenge.foo.example.com", @@ -144,14 +145,20 @@ func TestClient_AddRecord_error(t *testing.T) { } func TestClient_DeleteRecord(t *testing.T) { - client := setupTest(t, "DELETE /domain/123/dns", http.StatusOK, "") + client := mockBuilder(). + Route("DELETE /domain/123/dns", nil). + Build(t) err := client.DeleteRecord(t.Context(), 123, 456) require.NoError(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { - client := setupTest(t, "DELETE /domain/123/dns", http.StatusForbidden, "error.json") + client := mockBuilder(). + Route("DELETE /domain/123/dns", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusForbidden)). + Build(t) err := client.DeleteRecord(t.Context(), 123, 456) require.Error(t, err) diff --git a/providers/dns/rcodezero/internal/client_test.go b/providers/dns/rcodezero/internal/client_test.go index 0b54fa97f..b70107072 100644 --- a/providers/dns/rcodezero/internal/client_test.go +++ b/providers/dns/rcodezero/internal/client_test.go @@ -1,68 +1,30 @@ package internal import ( - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, method, pattern string, status int, file string) *Client { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusBadRequest) - return - } - - apiToken := req.Header.Get(authorizationHeader) - if apiToken != "Bearer secret" { - http.Error(rw, fmt.Sprintf("invalid credentials: %s", apiToken), http.StatusBadRequest) - return - } - - if file == "" { - rw.WriteHeader(status) - return - } - - open, err := os.Open(filepath.Join("fixtures", file)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = open.Close() }() - - rw.WriteHeader(status) - _, err = io.Copy(rw, open) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - +func setupClient(server *httptest.Server) (*Client, error) { client := NewClient("secret") client.HTTPClient = server.Client() client.baseURL, _ = url.Parse(server.URL) - return client + return client, nil } func TestClient_UpdateRecords_error(t *testing.T) { - client := setupTest(t, http.MethodPatch, "/v1/acme/zones/example.org/rrsets", http.StatusUnprocessableEntity, "error.json") + client := servermock.NewBuilder[*Client](setupClient, servermock.CheckHeader().WithJSONHeaders()). + Route("PATCH /v1/acme/zones/example.org/rrsets", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnprocessableEntity)). + Build(t) rrSet := []UpdateRRSet{{ Name: "acme.example.org.", @@ -77,7 +39,10 @@ func TestClient_UpdateRecords_error(t *testing.T) { } func TestClient_UpdateRecords(t *testing.T) { - client := setupTest(t, http.MethodPatch, "/v1/acme/zones/example.org/rrsets", http.StatusOK, "rrsets-response.json") + client := servermock.NewBuilder[*Client](setupClient, servermock.CheckHeader().WithJSONHeaders()). + Route("PATCH /v1/acme/zones/example.org/rrsets", + servermock.ResponseFromFixture("rrsets-response.json")). + Build(t) rrSet := []UpdateRRSet{{ Name: "acme.example.org.", diff --git a/providers/dns/regru/internal/client_test.go b/providers/dns/regru/internal/client_test.go index 4b4a9c8f4..0779f0d5f 100644 --- a/providers/dns/regru/internal/client_test.go +++ b/providers/dns/regru/internal/client_test.go @@ -1,60 +1,59 @@ package internal import ( - "net/http" + "net/http/httptest" "net/url" - "os" "testing" - "time" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) -const ( - noopBaseURL = "https://api.reg.ru/api/regru2/nop" - officialTestUser = "test" - officialTestPassword = "test" -) +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("user", "secret") + client.baseURL, _ = url.Parse(server.URL) + + return client, nil + }, + servermock.CheckHeader(). + WithContentTypeFromURLEncoded(), + ) +} func TestRemoveRecord(t *testing.T) { - // TODO(ldez): remove skip when the reg.ru API will be fixed. - t.Skip("there is a bug with the reg.ru API: INTERNAL_API_ERROR: Внутренняя ошибка, status code: 503") - - client := NewClient(officialTestUser, officialTestPassword) - client.HTTPClient = &http.Client{Timeout: 30 * time.Second} + client := mockBuilder(). + Route("POST /zone/remove_record", + servermock.ResponseFromFixture("remove_record.json"), + servermock.CheckForm().Strict(). + With("input_data", `{"domains":[{"dname":"test.ru"}],"subdomain":"_acme-challenge","content":"txttxttxt","record_type":"TXT","output_content_type":"plain"}`). + With("username", "user"). + With("password", "secret"). + With("input_format", "json")). + Build(t) err := client.RemoveTxtRecord(t.Context(), "test.ru", "_acme-challenge", "txttxttxt") require.NoError(t, err) } func TestRemoveRecord_errors(t *testing.T) { - // TODO(ldez): remove skip when the reg.ru API will be fixed. - if os.Getenv("CI") == "true" { - t.Skip("there is a bug with the reg.ru and GitHub action: dial tcp 194.58.116.30:443: i/o timeout") - } - testCases := []struct { desc string domain string - username string - password string - baseURL string + response string expected string }{ { desc: "authentication failed", domain: "test.ru", - username: "", - password: "", - baseURL: noopBaseURL, + response: "remove_record_error_auth.json", expected: "API error: NO_AUTH: No authorization mechanism selected", }, { desc: "domain error", domain: "", - username: officialTestUser, - password: officialTestPassword, - baseURL: defaultBaseURL, + response: "remove_record_error_domain.json", expected: "API error: NO_DOMAIN: domain_name not given or empty", }, } @@ -63,9 +62,9 @@ func TestRemoveRecord_errors(t *testing.T) { t.Run(test.desc, func(t *testing.T) { t.Parallel() - client := NewClient(test.username, test.username) - client.HTTPClient = &http.Client{Timeout: 30 * time.Second} - client.baseURL, _ = url.Parse(test.baseURL) + client := mockBuilder(). + Route("POST /zone/remove_record", servermock.ResponseFromFixture(test.response)). + Build(t) err := client.RemoveTxtRecord(t.Context(), test.domain, "_acme-challenge", "txttxttxt") require.EqualError(t, err, test.expected) @@ -74,44 +73,37 @@ func TestRemoveRecord_errors(t *testing.T) { } func TestAddTXTRecord(t *testing.T) { - // TODO(ldez): remove skip when the reg.ru API will be fixed. - t.Skip("there is a bug with the reg.ru API: INTERNAL_API_ERROR: Внутренняя ошибка, status code: 503") - - client := NewClient(officialTestUser, officialTestPassword) - client.HTTPClient = &http.Client{Timeout: 30 * time.Second} + client := mockBuilder(). + Route("POST /zone/add_txt", + servermock.ResponseFromFixture("add_txt_record.json"), + servermock.CheckForm().Strict(). + With("input_data", `{"domains":[{"dname":"test.ru"}],"subdomain":"_acme-challenge","text":"txttxttxt","output_content_type":"plain"}`). + With("username", "user"). + With("password", "secret"). + With("input_format", "json")). + Build(t) err := client.AddTXTRecord(t.Context(), "test.ru", "_acme-challenge", "txttxttxt") require.NoError(t, err) } func TestAddTXTRecord_errors(t *testing.T) { - // TODO(ldez): remove skip when the reg.ru API will be fixed. - if os.Getenv("CI") == "true" { - t.Skip("there is a bug with the reg.ru and GitHub action: dial tcp 194.58.116.30:443: i/o timeout") - } - testCases := []struct { desc string domain string - username string - password string - baseURL string + response string expected string }{ { desc: "authentication failed", domain: "test.ru", - username: "", - password: "", - baseURL: noopBaseURL, + response: "add_txt_record_error_auth.json", expected: "API error: NO_AUTH: No authorization mechanism selected", }, { desc: "domain error", domain: "", - username: officialTestUser, - password: officialTestPassword, - baseURL: defaultBaseURL, + response: "add_txt_record_error_domain.json", expected: "API error: NO_DOMAIN: domain_name not given or empty", }, } @@ -120,9 +112,9 @@ func TestAddTXTRecord_errors(t *testing.T) { t.Run(test.desc, func(t *testing.T) { t.Parallel() - client := NewClient(test.username, test.username) - client.HTTPClient = &http.Client{Timeout: 30 * time.Second} - client.baseURL, _ = url.Parse(test.baseURL) + client := mockBuilder(). + Route("POST /zone/add_txt", servermock.ResponseFromFixture(test.response)). + Build(t) err := client.AddTXTRecord(t.Context(), test.domain, "_acme-challenge", "txttxttxt") require.EqualError(t, err, test.expected) diff --git a/providers/dns/regru/internal/fixtures/add_txt_record.json b/providers/dns/regru/internal/fixtures/add_txt_record.json new file mode 100644 index 000000000..06306b4c4 --- /dev/null +++ b/providers/dns/regru/internal/fixtures/add_txt_record.json @@ -0,0 +1,14 @@ +{ + "answer": { + "domains": [ + { + "dname": "test.ru", + "result": "success", + "service_id": 12345 + } + ] + }, + "charset": "utf-8", + "messagestore": null, + "result": "success" +} diff --git a/providers/dns/regru/internal/fixtures/add_txt_record_error_auth.json b/providers/dns/regru/internal/fixtures/add_txt_record_error_auth.json new file mode 100644 index 000000000..2d5314bf3 --- /dev/null +++ b/providers/dns/regru/internal/fixtures/add_txt_record_error_auth.json @@ -0,0 +1,10 @@ +{ + "charset": "utf-8", + "error_code": "NO_AUTH", + "error_params": { + "command_name": "nop/zone/add_txt" + }, + "error_text": "No authorization mechanism selected", + "messagestore": null, + "result": "error" +} diff --git a/providers/dns/regru/internal/fixtures/add_txt_record_error_domain.json b/providers/dns/regru/internal/fixtures/add_txt_record_error_domain.json new file mode 100644 index 000000000..305846ed1 --- /dev/null +++ b/providers/dns/regru/internal/fixtures/add_txt_record_error_domain.json @@ -0,0 +1,14 @@ +{ + "answer": { + "domains": [ + { + "error_code": "NO_DOMAIN", + "error_text": "domain_name not given or empty", + "result": "error" + } + ] + }, + "charset": "utf-8", + "messagestore": null, + "result": "success" +} diff --git a/providers/dns/regru/internal/fixtures/remove_record.json b/providers/dns/regru/internal/fixtures/remove_record.json new file mode 100644 index 000000000..06306b4c4 --- /dev/null +++ b/providers/dns/regru/internal/fixtures/remove_record.json @@ -0,0 +1,14 @@ +{ + "answer": { + "domains": [ + { + "dname": "test.ru", + "result": "success", + "service_id": 12345 + } + ] + }, + "charset": "utf-8", + "messagestore": null, + "result": "success" +} diff --git a/providers/dns/regru/internal/fixtures/remove_record_error_auth.json b/providers/dns/regru/internal/fixtures/remove_record_error_auth.json new file mode 100644 index 000000000..98c429c53 --- /dev/null +++ b/providers/dns/regru/internal/fixtures/remove_record_error_auth.json @@ -0,0 +1,10 @@ +{ + "charset" : "utf-8", + "error_code" : "NO_AUTH", + "error_params" : { + "command_name" : "nop/zone/remove_record" + }, + "error_text" : "No authorization mechanism selected", + "messagestore" : null, + "result" : "error" +} diff --git a/providers/dns/regru/internal/fixtures/remove_record_error_domain.json b/providers/dns/regru/internal/fixtures/remove_record_error_domain.json new file mode 100644 index 000000000..a9ca88ff7 --- /dev/null +++ b/providers/dns/regru/internal/fixtures/remove_record_error_domain.json @@ -0,0 +1,14 @@ +{ + "answer" : { + "domains" : [ + { + "error_code" : "NO_DOMAIN", + "error_text" : "domain_name not given or empty", + "result" : "error" + } + ] + }, + "charset" : "utf-8", + "messagestore" : null, + "result" : "success" +} diff --git a/providers/dns/regru/internal/readme.md b/providers/dns/regru/internal/readme.md new file mode 100644 index 000000000..5f13012d2 --- /dev/null +++ b/providers/dns/regru/internal/readme.md @@ -0,0 +1,6 @@ +Test account (with the default endpoint): +- user: `test` +- password: `test` + +Noop endpoint: +- https://api.reg.ru/api/regru2/nop diff --git a/providers/dns/route53/fixtures/changeResourceRecordSetsResponse.xml b/providers/dns/route53/fixtures/changeResourceRecordSetsResponse.xml new file mode 100644 index 000000000..68dba580f --- /dev/null +++ b/providers/dns/route53/fixtures/changeResourceRecordSetsResponse.xml @@ -0,0 +1,8 @@ + + + + /change/123456 + PENDING + 2016-02-10T01:36:41.958Z + + diff --git a/providers/dns/route53/fixtures/getChangeResponse.xml b/providers/dns/route53/fixtures/getChangeResponse.xml new file mode 100644 index 000000000..f22c09460 --- /dev/null +++ b/providers/dns/route53/fixtures/getChangeResponse.xml @@ -0,0 +1,8 @@ + + + + 123456 + INSYNC + 2016-02-10T01:36:41.958Z + + diff --git a/providers/dns/route53/fixtures/listHostedZonesByNameResponse.xml b/providers/dns/route53/fixtures/listHostedZonesByNameResponse.xml new file mode 100644 index 000000000..db47ba1e1 --- /dev/null +++ b/providers/dns/route53/fixtures/listHostedZonesByNameResponse.xml @@ -0,0 +1,19 @@ + + + + + /hostedzone/ABCDEFG + example.com. + D2224C5B-684A-DB4A-BB9A-E09E3BAFEA7A + + Test comment + false + + 10 + + + true + example2.com + ZLT12321321124 + 1 + diff --git a/providers/dns/route53/fixtures_test.go b/providers/dns/route53/fixtures_test.go deleted file mode 100644 index 444a88003..000000000 --- a/providers/dns/route53/fixtures_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package route53 - -const ChangeResourceRecordSetsResponse = ` - - - /change/123456 - PENDING - 2016-02-10T01:36:41.958Z - -` - -const ListHostedZonesByNameResponse = ` - - - - /hostedzone/ABCDEFG - example.com. - D2224C5B-684A-DB4A-BB9A-E09E3BAFEA7A - - Test comment - false - - 10 - - - true - example2.com - ZLT12321321124 - 1 -` - -const GetChangeResponse = ` - - - 123456 - INSYNC - 2016-02-10T01:36:41.958Z - -` diff --git a/providers/dns/route53/mock_test.go b/providers/dns/route53/mock_test.go deleted file mode 100644 index 022767385..000000000 --- a/providers/dns/route53/mock_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package route53 - -import ( - "fmt" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/stretchr/testify/require" -) - -// MockResponse represents a predefined response used by a mock server. -type MockResponse struct { - StatusCode int - Body string -} - -// MockResponseMap maps request paths to responses. -type MockResponseMap map[string]MockResponse - -func setupTest(t *testing.T, responses MockResponseMap) string { - t.Helper() - - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path - resp, ok := responses[path] - if !ok { - resp, ok = responses[r.RequestURI] - if !ok { - msg := fmt.Sprintf("Requested path not found in response map: %s", path) - require.FailNow(t, msg) - } - } - - w.Header().Set("Content-Type", "application/xml") - w.WriteHeader(resp.StatusCode) - _, err := w.Write([]byte(resp.Body)) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) - - server := httptest.NewServer(handler) - t.Cleanup(server.Close) - - time.Sleep(100 * time.Millisecond) - - return server.URL -} diff --git a/providers/dns/route53/route53_test.go b/providers/dns/route53/route53_test.go index 60901de6d..6079bb4e6 100644 --- a/providers/dns/route53/route53_test.go +++ b/providers/dns/route53/route53_test.go @@ -1,6 +1,7 @@ package route53 import ( + "net/http/httptest" "os" "testing" "time" @@ -10,6 +11,7 @@ import ( "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/route53" "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -30,22 +32,6 @@ var envTest = tester.NewEnvTest( WithDomain(envDomain). WithLiveTestRequirements(EnvAccessKeyID, EnvSecretAccessKey, EnvRegion, envDomain) -func makeTestProvider(t *testing.T, serverURL string) *DNSProvider { - t.Helper() - - cfg := aws.Config{ - Credentials: credentials.NewStaticCredentialsProvider("abc", "123", " "), - Region: "mock-region", - BaseEndpoint: aws.String(serverURL), - RetryMaxAttempts: 1, - } - - return &DNSProvider{ - client: route53.NewFromConfig(cfg), - config: NewDefaultConfig(), - } -} - func Test_loadCredentials_FromEnv(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() @@ -154,21 +140,42 @@ func TestNewDefaultConfig(t *testing.T) { } func TestDNSProvider_Present(t *testing.T) { - mockResponses := MockResponseMap{ - "/2013-04-01/hostedzonesbyname": {StatusCode: 200, Body: ListHostedZonesByNameResponse}, - "/2013-04-01/hostedzone/ABCDEFG/rrset": {StatusCode: 200, Body: ChangeResourceRecordSetsResponse}, - "/2013-04-01/change/123456": {StatusCode: 200, Body: GetChangeResponse}, - "/2013-04-01/hostedzone/ABCDEFG/rrset?name=_acme-challenge.example.com.&type=TXT": { - StatusCode: 200, - Body: "", - }, - } - - serverURL := setupTest(t, mockResponses) - defer envTest.RestoreEnv() envTest.ClearEnv() - provider := makeTestProvider(t, serverURL) + + provider := servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + cfg := aws.Config{ + Credentials: credentials.NewStaticCredentialsProvider("abc", "123", " "), + Region: "mock-region", + BaseEndpoint: aws.String(server.URL), + RetryMaxAttempts: 1, + } + + return &DNSProvider{ + client: route53.NewFromConfig(cfg), + config: NewDefaultConfig(), + }, nil + }, + ). + Route("GET /2013-04-01/hostedzonesbyname", + servermock.ResponseFromFixture("listHostedZonesByNameResponse.xml"). + WithHeader("Content-Type", "application/xml"), + servermock.CheckQueryParameter().Strict(). + With("dnsname", "example.com")). + Route("POST /2013-04-01/hostedzone/ABCDEFG/rrset", + servermock.ResponseFromFixture("changeResourceRecordSetsResponse.xml"). + WithHeader("Content-Type", "application/xml")). + Route("GET /2013-04-01/change/123456", + servermock.ResponseFromFixture("getChangeResponse.xml"). + WithHeader("Content-Type", "application/xml")). + Route("GET /2013-04-01/hostedzone/ABCDEFG/rrset", + servermock.Noop(). + WithHeader("Content-Type", "application/xml"), + servermock.CheckQueryParameter().Strict(). + With("name", "_acme-challenge.example.com."). + With("type", "TXT")). + Build(t) domain := "example.com" keyAuth := "123456d==" diff --git a/providers/dns/safedns/internal/client_test.go b/providers/dns/safedns/internal/client_test.go index c00a8b5a7..117a85a9f 100644 --- a/providers/dns/safedns/internal/client_test.go +++ b/providers/dns/safedns/internal/client_test.go @@ -1,74 +1,36 @@ package internal import ( - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "strings" "testing" "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("secret") + client.baseURL, _ = url.Parse(server.URL) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := NewClient("secret") - client.baseURL, _ = url.Parse(server.URL) - - return client, mux + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(), + ) } func TestClient_AddRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/zones/example.com/records", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - if req.Header.Get(authorizationHeader) != "secret" { - http.Error(rw, `{"message":"Unauthenticated"}`, http.StatusUnauthorized) - return - } - - reqBody, err := io.ReadAll(req.Body) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - expectedReqBody := `{"name":"_acme-challenge.example.com","type":"TXT","content":"\"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI\"","ttl":120}` - if strings.TrimSpace(string(reqBody)) != expectedReqBody { - http.Error(rw, `{"message":"invalid request"}`, http.StatusBadRequest) - return - } - - resp := `{ - "data": { - "id": 1234567 - }, - "meta": { - "location": "https://api.ukfast.io/safedns/v1/zones/example.com/records/1234567" - } - }` - - rw.WriteHeader(http.StatusCreated) - _, err = fmt.Fprint(rw, resp) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder(). + Route("POST /zones/example.com/records", + servermock.ResponseFromFixture("add_record.json"). + WithStatusCode(http.StatusCreated), + servermock.CheckRequestJSONBodyFromFile("add_record-request.json")). + Build(t) record := Record{ Name: "_acme-challenge.example.com", @@ -96,23 +58,42 @@ func TestClient_AddRecord(t *testing.T) { assert.Equal(t, expected, response) } +func TestClient_AddRecord_error(t *testing.T) { + client := mockBuilder(). + Route("POST /zones/example.com/records", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) + + record := Record{ + Name: "_acme-challenge.example.com", + Type: "TXT", + Content: `"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI"`, + TTL: dns01.DefaultTTL, + } + + _, err := client.AddRecord(t.Context(), "example.com", record) + require.EqualError(t, err, "add record: [status code: 401] Unauthenticated") +} + func TestClient_RemoveRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/zones/example.com/records/1234567", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodDelete { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - if req.Header.Get(authorizationHeader) != "secret" { - http.Error(rw, `{"message":"Unauthenticated"}`, http.StatusUnauthorized) - return - } - - rw.WriteHeader(http.StatusNoContent) - }) + client := mockBuilder(). + Route("DELETE /zones/example.com/records/1234567", + servermock.Noop(). + WithStatusCode(http.StatusNoContent)). + Build(t) err := client.RemoveRecord(t.Context(), "example.com", 1234567) require.NoError(t, err) } + +func TestClient_RemoveRecord_error(t *testing.T) { + client := mockBuilder(). + Route("DELETE /zones/example.com/records/1234567", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) + + err := client.RemoveRecord(t.Context(), "example.com", 1234567) + require.EqualError(t, err, "remove record: [status code: 401] Unauthenticated") +} diff --git a/providers/dns/safedns/internal/fixtures/add_record-request.json b/providers/dns/safedns/internal/fixtures/add_record-request.json new file mode 100644 index 000000000..71c8813f2 --- /dev/null +++ b/providers/dns/safedns/internal/fixtures/add_record-request.json @@ -0,0 +1,6 @@ +{ + "name": "_acme-challenge.example.com", + "type": "TXT", + "content": "\"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI\"", + "ttl": 120 +} diff --git a/providers/dns/safedns/internal/fixtures/add_record.json b/providers/dns/safedns/internal/fixtures/add_record.json new file mode 100644 index 000000000..f3c4ad883 --- /dev/null +++ b/providers/dns/safedns/internal/fixtures/add_record.json @@ -0,0 +1,8 @@ +{ + "data": { + "id": 1234567 + }, + "meta": { + "location": "https://api.ukfast.io/safedns/v1/zones/example.com/records/1234567" + } +} diff --git a/providers/dns/safedns/internal/fixtures/error.json b/providers/dns/safedns/internal/fixtures/error.json new file mode 100644 index 000000000..47fb5916c --- /dev/null +++ b/providers/dns/safedns/internal/fixtures/error.json @@ -0,0 +1,3 @@ +{ + "message": "Unauthenticated" +} diff --git a/providers/dns/selectelv2/selectelv2.go b/providers/dns/selectelv2/selectelv2.go index 19e352d7f..ca0a9107d 100644 --- a/providers/dns/selectelv2/selectelv2.go +++ b/providers/dns/selectelv2/selectelv2.go @@ -124,15 +124,15 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { // Timeout returns the Timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. -func (p *DNSProvider) Timeout() (timeout, interval time.Duration) { - return p.config.PropagationTimeout, p.config.PollingInterval +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record to fulfill DNS-01 challenge. -func (p *DNSProvider) Present(domain, _, keyAuth string) error { +func (d *DNSProvider) Present(domain, _, keyAuth string) error { ctx := context.Background() - client, err := p.authorize() + client, err := d.authorize() if err != nil { return fmt.Errorf("selectelv2: authorize: %w", err) } @@ -153,7 +153,7 @@ func (p *DNSProvider) Present(domain, _, keyAuth string) error { newRRSet := &selectelapi.RRSet{ Name: info.EffectiveFQDN, Type: selectelapi.TXT, - TTL: p.config.TTL, + TTL: d.config.TTL, Records: []selectelapi.RecordItem{{Content: fmt.Sprintf("%q", info.Value)}}, } @@ -176,10 +176,10 @@ func (p *DNSProvider) Present(domain, _, keyAuth string) error { } // CleanUp removes a TXT record used for DNS-01 challenge. -func (p *DNSProvider) CleanUp(domain, _, keyAuth string) error { +func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error { ctx := context.Background() - client, err := p.authorize() + client, err := d.authorize() if err != nil { return fmt.Errorf("selectelv2: authorize: %w", err) } @@ -220,8 +220,8 @@ func (p *DNSProvider) CleanUp(domain, _, keyAuth string) error { return nil } -func (p *DNSProvider) authorize() (*clientWrapper, error) { - token, err := obtainOpenstackToken(p.config) +func (d *DNSProvider) authorize() (*clientWrapper, error) { + token, err := obtainOpenstackToken(d.config) if err != nil { return nil, err } @@ -230,7 +230,7 @@ func (p *DNSProvider) authorize() (*clientWrapper, error) { extraHeaders.Set(tokenHeader, token) return &clientWrapper{ - DNSClient: p.baseClient.WithHeaders(extraHeaders), + DNSClient: d.baseClient.WithHeaders(extraHeaders), }, nil } diff --git a/providers/dns/selfhostde/internal/client_test.go b/providers/dns/selfhostde/internal/client_test.go index 88f627b02..22949728c 100644 --- a/providers/dns/selfhostde/internal/client_test.go +++ b/providers/dns/selfhostde/internal/client_test.go @@ -1,64 +1,41 @@ package internal import ( - "fmt" "net/http" "net/http/httptest" - "net/url" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - +func setupClient(server *httptest.Server) (*Client, error) { client := NewClient("user", "secret") - serverURL, err := url.Parse(server.URL) - require.NoError(t, err) + client.baseURL = server.URL + client.HTTPClient = server.Client() - client.baseURL = serverURL.String() - - return client, mux + return client, nil } func TestClient_UpdateTXTRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("GET /", func(rw http.ResponseWriter, req *http.Request) { - query := req.URL.Query() - - fields := map[string]string{ - "username": "user", - "password": "secret", - "rid": "123456", - "content": "txt", - } - - for k, v := range fields { - value := query.Get(k) - if value != v { - http.Error(rw, fmt.Sprintf("%s: unexpected value: %s (%s)", k, value, v), http.StatusBadRequest) - return - } - } - }) + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /", nil, servermock.CheckQueryParameter().Strict(). + With("rid", "123456"). + With("content", "txt"). + With("username", "user"). + With("password", "secret"), + ). + Build(t) err := client.UpdateTXTRecord(t.Context(), "123456", "txt") require.NoError(t, err) } func TestClient_UpdateTXTRecord_error(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("GET /", func(rw http.ResponseWriter, _ *http.Request) { - http.Error(rw, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) - }) + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /", servermock.Noop().WithStatusCode(http.StatusBadRequest)). + Build(t) err := client.UpdateTXTRecord(t.Context(), "123456", "txt") - require.Error(t, err) + require.EqualError(t, err, "unexpected status code: [status code: 400] body: ") } diff --git a/providers/dns/servercow/internal/client_test.go b/providers/dns/servercow/internal/client_test.go index b171b6408..2092bf907 100644 --- a/providers/dns/servercow/internal/client_test.go +++ b/providers/dns/servercow/internal/client_test.go @@ -2,53 +2,35 @@ package internal import ( "encoding/json" - "io" - "net/http" "net/http/httptest" "net/url" "os" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("user", "secret") + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := NewClient("", "") - client.HTTPClient = server.Client() - client.baseURL, _ = url.Parse(server.URL) - - return client, mux + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + With("X-Auth-Username", "user"). + With("X-Auth-Password", "secret"), + ) } func TestClient_GetRecords(t *testing.T) { - client, handler := setupTest(t) - - handler.HandleFunc("/lego.wtf", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - file, err := os.Open("./fixtures/records-01.json") - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder(). + Route("GET /lego.wtf", servermock.ResponseFromFixture("records-01.json")). + Build(t) records, err := client.GetRecords(t.Context(), "lego.wtf") require.NoError(t, err) @@ -63,20 +45,9 @@ func TestClient_GetRecords(t *testing.T) { } func TestClient_GetRecords_error(t *testing.T) { - client, handler := setupTest(t) - - handler.HandleFunc("/lego.wtf", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - err := json.NewEncoder(rw).Encode(Message{ErrorMsg: "authentication failed"}) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder(). + Route("GET /lego.wtf", servermock.JSONEncode(Message{ErrorMsg: "authentication failed"})). + Build(t) records, err := client.GetRecords(t.Context(), "lego.wtf") require.Error(t, err) @@ -85,33 +56,11 @@ func TestClient_GetRecords_error(t *testing.T) { } func TestClient_CreateUpdateRecord(t *testing.T) { - client, handler := setupTest(t) - - handler.HandleFunc("/lego.wtf", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - content, err := io.ReadAll(req.Body) - if err != nil { - http.Error(rw, err.Error(), http.StatusBadRequest) - return - } - - expectedRequest := `{"name":"_acme-challenge.www","type":"TXT","ttl":30,"content":["aaa","bbb"]}` - - if !assert.JSONEq(t, expectedRequest, string(content)) { - http.Error(rw, "invalid content", http.StatusBadRequest) - return - } - - err = json.NewEncoder(rw).Encode(Message{Message: "ok"}) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder(). + Route("POST /lego.wtf", + servermock.JSONEncode(Message{Message: "ok"}), + servermock.CheckRequestJSONBody(`{"name":"_acme-challenge.www","type":"TXT","ttl":30,"content":["aaa","bbb"]}`)). + Build(t) record := Record{ Name: "_acme-challenge.www", @@ -128,20 +77,10 @@ func TestClient_CreateUpdateRecord(t *testing.T) { } func TestClient_CreateUpdateRecord_error(t *testing.T) { - client, handler := setupTest(t) - - handler.HandleFunc("/lego.wtf", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - err := json.NewEncoder(rw).Encode(Message{ErrorMsg: "parameter type must be cname, txt, tlsa, caa, a or aaaa"}) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder(). + Route("POST /lego.wtf", + servermock.JSONEncode(Message{ErrorMsg: "parameter type must be cname, txt, tlsa, caa, a or aaaa"})). + Build(t) record := Record{ Name: "_acme-challenge.www", @@ -154,33 +93,11 @@ func TestClient_CreateUpdateRecord_error(t *testing.T) { } func TestClient_DeleteRecord(t *testing.T) { - client, handler := setupTest(t) - - handler.HandleFunc("/lego.wtf", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodDelete { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - content, err := io.ReadAll(req.Body) - if err != nil { - http.Error(rw, err.Error(), http.StatusBadRequest) - return - } - - expectedRequest := `{"name":"_acme-challenge.www","type":"TXT"}` - - if !assert.JSONEq(t, expectedRequest, string(content)) { - http.Error(rw, "invalid content", http.StatusBadRequest) - return - } - - err = json.NewEncoder(rw).Encode(Message{Message: "ok"}) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder(). + Route("DELETE /lego.wtf", + servermock.JSONEncode(Message{Message: "ok"}), + servermock.CheckRequestJSONBody(`{"name":"_acme-challenge.www","type":"TXT"}`)). + Build(t) record := Record{ Name: "_acme-challenge.www", @@ -195,20 +112,10 @@ func TestClient_DeleteRecord(t *testing.T) { } func TestClient_DeleteRecord_error(t *testing.T) { - client, handler := setupTest(t) - - handler.HandleFunc("/lego.wtf", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodDelete { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - err := json.NewEncoder(rw).Encode(Message{ErrorMsg: "parameter type must be cname, txt, tlsa, caa, a or aaaa"}) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder(). + Route("DELETE /lego.wtf", + servermock.JSONEncode(Message{ErrorMsg: "parameter type must be cname, txt, tlsa, caa, a or aaaa"})). + Build(t) record := Record{ Name: "_acme-challenge.www", diff --git a/providers/dns/shellrent/internal/client_test.go b/providers/dns/shellrent/internal/client_test.go index c160ddf56..7047ce835 100644 --- a/providers/dns/shellrent/internal/client_test.go +++ b/providers/dns/shellrent/internal/client_test.go @@ -1,68 +1,33 @@ package internal import ( - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, method, pattern string, status int, file string) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("user", "secret") + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusBadRequest) - return - } - - auth := req.Header.Get(authorizationHeader) - if auth != "user.secret" { - http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return - } - - if file == "" { - rw.WriteHeader(status) - return - } - - open, err := os.Open(filepath.Join("fixtures", file)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = open.Close() }() - - rw.WriteHeader(status) - _, err = io.Copy(rw, open) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - client := NewClient("user", "secret") - client.HTTPClient = server.Client() - client.baseURL, _ = url.Parse(server.URL) - - return client + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("user.secret")) } func TestClient_ListServices(t *testing.T) { - client := setupTest(t, http.MethodGet, "/purchase", http.StatusOK, "purchase.json") + client := mockBuilder(). + Route("GET /purchase", servermock.ResponseFromFixture("purchase.json")). + Build(t) services, err := client.ListServices(t.Context()) require.NoError(t, err) @@ -73,21 +38,29 @@ func TestClient_ListServices(t *testing.T) { } func TestClient_ListServices_error(t *testing.T) { - client := setupTest(t, http.MethodGet, "/purchase", http.StatusOK, "error.json") + client := mockBuilder(). + Route("GET /purchase", servermock.ResponseFromFixture("error.json")). + Build(t) _, err := client.ListServices(t.Context()) require.EqualError(t, err, "code 2: Token di autorizzazione non valido") } func TestClient_ListServices_error_status(t *testing.T) { - client := setupTest(t, http.MethodGet, "/purchase", http.StatusUnauthorized, "error.json") + client := mockBuilder(). + Route("GET /purchase", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) _, err := client.ListServices(t.Context()) require.EqualError(t, err, "code 2: Token di autorizzazione non valido") } func TestClient_GetServiceDetails(t *testing.T) { - client := setupTest(t, http.MethodGet, "/purchase/details/123", http.StatusOK, "purchase-details.json") + client := mockBuilder(). + Route("GET /purchase/details/123", servermock.ResponseFromFixture("purchase-details.json")). + Build(t) services, err := client.GetServiceDetails(t.Context(), 123) require.NoError(t, err) @@ -98,21 +71,29 @@ func TestClient_GetServiceDetails(t *testing.T) { } func TestClient_GetServiceDetails_error(t *testing.T) { - client := setupTest(t, http.MethodGet, "/purchase/details/123", http.StatusOK, "error.json") + client := mockBuilder(). + Route("GET /purchase/details/123", servermock.ResponseFromFixture("error.json")). + Build(t) _, err := client.GetServiceDetails(t.Context(), 123) require.EqualError(t, err, "code 2: Token di autorizzazione non valido") } func TestClient_GetServiceDetails_error_status(t *testing.T) { - client := setupTest(t, http.MethodGet, "/purchase/details/123", http.StatusUnauthorized, "error.json") + client := mockBuilder(). + Route("GET /purchase/details/123", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) _, err := client.GetServiceDetails(t.Context(), 123) require.EqualError(t, err, "code 2: Token di autorizzazione non valido") } func TestClient_GetDomainDetails(t *testing.T) { - client := setupTest(t, http.MethodGet, "/domain/details/123", http.StatusOK, "domain-details.json") + client := mockBuilder(). + Route("GET /domain/details/123", servermock.ResponseFromFixture("domain-details.json")). + Build(t) services, err := client.GetDomainDetails(t.Context(), 123) require.NoError(t, err) @@ -123,21 +104,29 @@ func TestClient_GetDomainDetails(t *testing.T) { } func TestClient_GetDomainDetails_error(t *testing.T) { - client := setupTest(t, http.MethodGet, "/domain/details/123", http.StatusOK, "error.json") + client := mockBuilder(). + Route("GET /domain/details/123", servermock.ResponseFromFixture("error.json")). + Build(t) _, err := client.GetDomainDetails(t.Context(), 123) require.EqualError(t, err, "code 2: Token di autorizzazione non valido") } func TestClient_GetDomainDetails_error_status(t *testing.T) { - client := setupTest(t, http.MethodGet, "/domain/details/123", http.StatusUnauthorized, "error.json") + client := mockBuilder(). + Route("GET /domain/details/123", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) _, err := client.GetDomainDetails(t.Context(), 123) require.EqualError(t, err, "code 2: Token di autorizzazione non valido") } func TestClient_CreateRecord(t *testing.T) { - client := setupTest(t, http.MethodPost, "/dns_record/store/123", http.StatusOK, "dns_record-store.json") + client := mockBuilder(). + Route("POST /dns_record/store/123", servermock.ResponseFromFixture("dns_record-store.json")). + Build(t) services, err := client.CreateRecord(t.Context(), 123, Record{}) require.NoError(t, err) @@ -148,35 +137,49 @@ func TestClient_CreateRecord(t *testing.T) { } func TestClient_CreateRecord_error(t *testing.T) { - client := setupTest(t, http.MethodPost, "/dns_record/store/123", http.StatusOK, "error.json") + client := mockBuilder(). + Route("POST /dns_record/store/123", servermock.ResponseFromFixture("error.json")). + Build(t) _, err := client.CreateRecord(t.Context(), 123, Record{}) require.EqualError(t, err, "code 2: Token di autorizzazione non valido") } func TestClient_CreateRecord_error_status(t *testing.T) { - client := setupTest(t, http.MethodPost, "/dns_record/store/123", http.StatusUnauthorized, "error.json") + client := mockBuilder(). + Route("POST /dns_record/store/123", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) _, err := client.CreateRecord(t.Context(), 123, Record{}) require.EqualError(t, err, "code 2: Token di autorizzazione non valido") } func TestClient_DeleteRecord(t *testing.T) { - client := setupTest(t, http.MethodDelete, "/dns_record/remove/123/456", http.StatusOK, "dns_record-remove.json") + client := mockBuilder(). + Route("DELETE /dns_record/remove/123/456", servermock.ResponseFromFixture("dns_record-remove.json")). + Build(t) err := client.DeleteRecord(t.Context(), 123, 456) require.NoError(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { - client := setupTest(t, http.MethodDelete, "/dns_record/remove/123/456", http.StatusOK, "error.json") + client := mockBuilder(). + Route("DELETE /dns_record/remove/123/456", servermock.ResponseFromFixture("error.json")). + Build(t) err := client.DeleteRecord(t.Context(), 123, 456) require.EqualError(t, err, "code 2: Token di autorizzazione non valido") } func TestClient_DeleteRecord_error_status(t *testing.T) { - client := setupTest(t, http.MethodDelete, "/dns_record/remove/123/456", http.StatusUnauthorized, "error.json") + client := mockBuilder(). + Route("DELETE /dns_record/remove/123/456", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) err := client.DeleteRecord(t.Context(), 123, 456) require.EqualError(t, err, "code 2: Token di autorizzazione non valido") diff --git a/providers/dns/simply/internal/client_test.go b/providers/dns/simply/internal/client_test.go index e822b03cf..83aa714bf 100644 --- a/providers/dns/simply/internal/client_test.go +++ b/providers/dns/simply/internal/client_test.go @@ -1,24 +1,37 @@ package internal import ( - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestClient_GetRecords(t *testing.T) { - client, mux := setupTest(t) +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient("accountname", "apikey") + if err != nil { + return nil, err + } - mux.HandleFunc("/accountname/apikey/my/products/azone01/dns/records", mockHandler(http.MethodGet, http.StatusOK, "get_records.json")) + client.baseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() + + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders()) +} + +func TestClient_GetRecords(t *testing.T) { + client := mockBuilder(). + Route("GET /accountname/apikey/my/products/azone01/dns/records", + servermock.ResponseFromFixture("get_records.json")). + Build(t) records, err := client.GetRecords(t.Context(), "azone01") require.NoError(t, err) @@ -62,9 +75,11 @@ func TestClient_GetRecords(t *testing.T) { } func TestClient_GetRecords_error(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/accountname/apikey/my/products/azone01/dns/records", mockHandler(http.MethodGet, http.StatusBadRequest, "bad_auth_error.json")) + client := mockBuilder(). + Route("GET /accountname/apikey/my/products/azone01/dns/records", + servermock.ResponseFromFixture("bad_auth_error.json"). + WithStatusCode(http.StatusBadRequest)). + Build(t) records, err := client.GetRecords(t.Context(), "azone01") require.Error(t, err) @@ -73,9 +88,10 @@ func TestClient_GetRecords_error(t *testing.T) { } func TestClient_AddRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/accountname/apikey/my/products/azone01/dns/records", mockHandler(http.MethodPost, http.StatusOK, "add_record.json")) + client := mockBuilder(). + Route("POST /accountname/apikey/my/products/azone01/dns/records", + servermock.ResponseFromFixture("add_record.json")). + Build(t) record := Record{ Name: "arecord01", @@ -92,9 +108,11 @@ func TestClient_AddRecord(t *testing.T) { } func TestClient_AddRecord_error(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/accountname/apikey/my/products/azone01/dns/records", mockHandler(http.MethodPost, http.StatusNotFound, "bad_zone_error.json")) + client := mockBuilder(). + Route("POST /accountname/apikey/my/products/azone01/dns/records", + servermock.ResponseFromFixture("bad_zone_error.json"). + WithStatusCode(http.StatusNotFound)). + Build(t) record := Record{ Name: "arecord01", @@ -111,9 +129,10 @@ func TestClient_AddRecord_error(t *testing.T) { } func TestClient_EditRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/accountname/apikey/my/products/azone01/dns/records/123456789", mockHandler(http.MethodPut, http.StatusOK, "success.json")) + client := mockBuilder(). + Route("PUT /accountname/apikey/my/products/azone01/dns/records/123456789", + servermock.ResponseFromFixture("success.json")). + Build(t) record := Record{ Name: "arecord01", @@ -128,9 +147,11 @@ func TestClient_EditRecord(t *testing.T) { } func TestClient_EditRecord_error(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/accountname/apikey/my/products/azone01/dns/records/123456789", mockHandler(http.MethodPut, http.StatusNotFound, "invalid_record_id.json")) + client := mockBuilder(). + Route("PUT /accountname/apikey/my/products/azone01/dns/records/123456789", + servermock.ResponseFromFixture("invalid_record_id.json"). + WithStatusCode(http.StatusNotFound)). + Build(t) record := Record{ Name: "arecord01", @@ -145,63 +166,22 @@ func TestClient_EditRecord_error(t *testing.T) { } func TestClient_DeleteRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/accountname/apikey/my/products/azone01/dns/records/123456789", mockHandler(http.MethodDelete, http.StatusOK, "success.json")) + client := mockBuilder(). + Route("DELETE /accountname/apikey/my/products/azone01/dns/records/123456789", + servermock.ResponseFromFixture("success.json")). + Build(t) err := client.DeleteRecord(t.Context(), "azone01", 123456789) require.NoError(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/accountname/apikey/my/products/azone01/dns/records/123456789", mockHandler(http.MethodDelete, http.StatusNotFound, "invalid_record_id.json")) + client := mockBuilder(). + Route("DELETE /accountname/apikey/my/products/azone01/dns/records/123456789", + servermock.ResponseFromFixture("invalid_record_id.json"). + WithStatusCode(http.StatusNotFound)). + Build(t) err := client.DeleteRecord(t.Context(), "azone01", 123456789) require.Error(t, err) } - -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client, err := NewClient("accountname", "apikey") - require.NoError(t, err) - - client.baseURL, _ = url.Parse(server.URL) - - return client, mux -} - -func mockHandler(method string, statusCode int, filename string) func(http.ResponseWriter, *http.Request) { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) - return - } - - if filename == "" { - rw.WriteHeader(statusCode) - return - } - - file, err := os.Open(filepath.FromSlash(path.Join("./fixtures", filename))) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - rw.WriteHeader(statusCode) - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - } -} diff --git a/providers/dns/sonic/internal/client_test.go b/providers/dns/sonic/internal/client_test.go index 618538780..751ccee8f 100644 --- a/providers/dns/sonic/internal/client_test.go +++ b/providers/dns/sonic/internal/client_test.go @@ -1,31 +1,23 @@ package internal import ( - "fmt" - "net/http" "net/http/httptest" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, body string) *Client { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc("/host", func(rw http.ResponseWriter, req *http.Request) { - _, _ = fmt.Fprintln(rw, body) - }) - +func setupClient(server *httptest.Server) (*Client, error) { client, err := NewClient("foo", "secret") - require.NoError(t, err) + if err != nil { + return nil, err + } client.baseURL = server.URL + client.HTTPClient = server.Client() - return client + return client, nil } func TestClient_SetRecord(t *testing.T) { @@ -50,7 +42,11 @@ func TestClient_SetRecord(t *testing.T) { t.Run(test.desc, func(t *testing.T) { t.Parallel() - client := setupTest(t, test.response) + client := servermock.NewBuilder[*Client](setupClient, servermock.CheckHeader().WithJSONHeaders()). + Route("PUT /host", + servermock.RawStringResponse(test.response), + servermock.CheckRequestJSONBody(`{"userid":"foo","apikey":"secret","hostname":"example.com","value":"txttxttxt","ttl":10,"type":"TXT"}`)). + Build(t) err := client.SetRecord(t.Context(), "example.com", "txttxttxt", 10) test.assert(t, err) diff --git a/providers/dns/spaceship/internal/client_test.go b/providers/dns/spaceship/internal/client_test.go index ec6787f8e..f32843652 100644 --- a/providers/dns/spaceship/internal/client_test.go +++ b/providers/dns/spaceship/internal/client_test.go @@ -1,58 +1,40 @@ package internal import ( - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, pattern string, status int, filename string) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient("key", "secret") + if err != nil { + return nil, err + } - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) - mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) { - if filename == "" { - rw.WriteHeader(status) - return - } - - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = file.Close() }() - - rw.WriteHeader(status) - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - client, err := NewClient("key", "secret") - require.NoError(t, err) - - client.HTTPClient = server.Client() - client.baseURL, _ = url.Parse(server.URL) - - return client + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + With("X-Api-Key", "key"). + With("X-Api-Secret", "secret"), + ) } func TestClient_AddRecord(t *testing.T) { - client := setupTest(t, "PUT /dns/records/example.com", http.StatusOK, "") + client := mockBuilder(). + Route("PUT /dns/records/example.com", nil, + servermock.CheckRequestJSONBody(`{"items":[{"type":"TXT","name":"@","ttl":60}]}`)). + Build(t) record := Record{ Type: "TXT", @@ -65,7 +47,11 @@ func TestClient_AddRecord(t *testing.T) { } func TestClient_AddRecord_error(t *testing.T) { - client := setupTest(t, "PUT /dns/records/example.com", http.StatusUnprocessableEntity, "error.json") + client := mockBuilder(). + Route("PUT /dns/records/example.com", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnprocessableEntity)). + Build(t) record := Record{ Type: "TXT", @@ -78,7 +64,10 @@ func TestClient_AddRecord_error(t *testing.T) { } func TestClient_DeleteRecord(t *testing.T) { - client := setupTest(t, "DELETE /dns/records/example.com", http.StatusOK, "") + client := mockBuilder(). + Route("DELETE /dns/records/example.com", nil, + servermock.CheckRequestJSONBody(`[{"type":"TXT","name":"@","ttl":60}]`)). + Build(t) record := Record{ Type: "TXT", @@ -91,7 +80,11 @@ func TestClient_DeleteRecord(t *testing.T) { } func TestClient_DeleteRecord_error(t *testing.T) { - client := setupTest(t, "DELETE /dns/records/example.com", http.StatusUnprocessableEntity, "error.json") + client := mockBuilder(). + Route("DELETE /dns/records/example.com", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnprocessableEntity)). + Build(t) record := Record{ Type: "TXT", @@ -104,7 +97,10 @@ func TestClient_DeleteRecord_error(t *testing.T) { } func TestClient_GetRecords(t *testing.T) { - client := setupTest(t, "GET /dns/records/example.com", http.StatusOK, "get-records.json") + client := mockBuilder(). + Route("GET /dns/records/example.com", + servermock.ResponseFromFixture("get-records.json")). + Build(t) records, err := client.GetRecords(t.Context(), "example.com") require.NoError(t, err) @@ -117,7 +113,11 @@ func TestClient_GetRecords(t *testing.T) { } func TestClient_GetRecords_error(t *testing.T) { - client := setupTest(t, "GET /dns/records/example.com", http.StatusUnprocessableEntity, "error.json") + client := mockBuilder(). + Route("GET /dns/records/example.com", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnprocessableEntity)). + Build(t) _, err := client.GetRecords(t.Context(), "example.com") require.EqualError(t, err, "^$, name: The domain name contains invalid characters") diff --git a/providers/dns/stackpath/internal/client_test.go b/providers/dns/stackpath/internal/client_test.go index cb56ef728..5195aa973 100644 --- a/providers/dns/stackpath/internal/client_test.go +++ b/providers/dns/stackpath/internal/client_test.go @@ -1,47 +1,37 @@ package internal import ( + "context" "net/http" "net/http/httptest" "net/url" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient(context.Background(), "STACK_ID", "CLIENT_ID", "CLIENT_SECRET") + client.httpClient = server.Client() + client.baseURL, _ = url.Parse(server.URL + "/") - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := NewClient(t.Context(), "STACK_ID", "CLIENT_ID", "CLIENT_SECRET") - client.httpClient = server.Client() - client.baseURL, _ = url.Parse(server.URL + "/") - - return client, mux + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(), + ) } func TestClient_GetZoneRecords(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/STACK_ID/zones/A/records", func(w http.ResponseWriter, _ *http.Request) { - content := ` - { - "records": [ - {"id":"1","name":"foo1","type":"TXT","ttl":120,"data":"txtTXTtxt"}, - {"id":"2","name":"foo2","type":"TXT","ttl":121,"data":"TXTtxtTXT"} - ] - }` - - _, err := w.Write([]byte(content)) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder(). + Route("GET /STACK_ID/zones/A/records", + servermock.ResponseFromFixture("get_zone_records.json"), + servermock.CheckQueryParameter().Strict(). + With("page_request.filter", "name='foo1' and type='TXT'")). + Build(t) records, err := client.GetZoneRecords(t.Context(), "foo1", &Zone{ID: "A", Domain: "test"}) require.NoError(t, err) @@ -55,22 +45,14 @@ func TestClient_GetZoneRecords(t *testing.T) { } func TestClient_GetZoneRecords_apiError(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/STACK_ID/zones/A/records", func(w http.ResponseWriter, _ *http.Request) { - content := ` + client := mockBuilder(). + Route("GET /STACK_ID/zones/A/records", + servermock.RawStringResponse(` { "code": 401, "error": "an unauthorized request is attempted." -}` - - w.WriteHeader(http.StatusUnauthorized) - _, err := w.Write([]byte(content)) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) +}`).WithStatusCode(http.StatusUnauthorized)). + Build(t) _, err := client.GetZoneRecords(t.Context(), "foo1", &Zone{ID: "A", Domain: "test"}) @@ -79,47 +61,12 @@ func TestClient_GetZoneRecords_apiError(t *testing.T) { } func TestClient_GetZones(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/STACK_ID/zones", func(w http.ResponseWriter, _ *http.Request) { - content := ` -{ - "pageInfo": { - "totalCount": "5", - "hasPreviousPage": false, - "hasNextPage": false, - "startCursor": "1", - "endCursor": "1" - }, - "zones": [ - { - "stackId": "my_stack", - "accountId": "my_account", - "id": "A", - "domain": "foo.com", - "version": "1", - "labels": { - "property1": "val1", - "property2": "val2" - }, - "created": "2018-10-07T02:31:49Z", - "updated": "2018-10-07T02:31:49Z", - "nameservers": [ - "1.1.1.1" - ], - "verified": "2018-10-07T02:31:49Z", - "status": "ACTIVE", - "disabled": false - } - ] -}` - - _, err := w.Write([]byte(content)) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder(). + Route("GET /STACK_ID/zones", + servermock.ResponseFromFixture("get_zones.json"), + servermock.CheckQueryParameter().Strict(). + With("page_request.filter", "domain='foo.com'")). + Build(t) zone, err := client.GetZones(t.Context(), "sub.foo.com") require.NoError(t, err) diff --git a/providers/dns/stackpath/internal/fixtures/get_zone_records.json b/providers/dns/stackpath/internal/fixtures/get_zone_records.json new file mode 100644 index 000000000..1556d08fe --- /dev/null +++ b/providers/dns/stackpath/internal/fixtures/get_zone_records.json @@ -0,0 +1,6 @@ +{ + "records": [ + {"id":"1","name":"foo1","type":"TXT","ttl":120,"data":"txtTXTtxt"}, + {"id":"2","name":"foo2","type":"TXT","ttl":121,"data":"TXTtxtTXT"} + ] +} diff --git a/providers/dns/stackpath/internal/fixtures/get_zones.json b/providers/dns/stackpath/internal/fixtures/get_zones.json new file mode 100644 index 000000000..7630ef4fe --- /dev/null +++ b/providers/dns/stackpath/internal/fixtures/get_zones.json @@ -0,0 +1,30 @@ +{ + "pageInfo": { + "totalCount": "5", + "hasPreviousPage": false, + "hasNextPage": false, + "startCursor": "1", + "endCursor": "1" + }, + "zones": [ + { + "stackId": "my_stack", + "accountId": "my_account", + "id": "A", + "domain": "foo.com", + "version": "1", + "labels": { + "property1": "val1", + "property2": "val2" + }, + "created": "2018-10-07T02:31:49Z", + "updated": "2018-10-07T02:31:49Z", + "nameservers": [ + "1.1.1.1" + ], + "verified": "2018-10-07T02:31:49Z", + "status": "ACTIVE", + "disabled": false + } + ] +} diff --git a/providers/dns/technitium/internal/client_test.go b/providers/dns/technitium/internal/client_test.go index f8b0d049b..cd6914918 100644 --- a/providers/dns/technitium/internal/client_test.go +++ b/providers/dns/technitium/internal/client_test.go @@ -1,50 +1,39 @@ package internal import ( - "io" - "net/http" "net/http/httptest" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, pattern, filename string) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient(server.URL, "secret") + if err != nil { + return nil, err + } - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) + client.HTTPClient = server.Client() - mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) { - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - client, err := NewClient(server.URL, "secret") - require.NoError(t, err) - - client.HTTPClient = server.Client() - - return client + return client, nil + }, + servermock.CheckHeader().WithContentTypeFromURLEncoded()) } func TestClient_AddRecord(t *testing.T) { - client := setupTest(t, "POST /api/zones/records/add", "add-record.json") + client := mockBuilder(). + Route("POST /api/zones/records/add", + servermock.ResponseFromFixture("add-record.json"), + servermock.CheckForm().Strict(). + With("domain", "_acme-challenge.example.com"). + With("text", "txtTXTtxt"). + With("type", "TXT"). + With("token", "secret")). + Build(t) record := Record{ Domain: "_acme-challenge.example.com", @@ -61,7 +50,10 @@ func TestClient_AddRecord(t *testing.T) { } func TestClient_AddRecord_error(t *testing.T) { - client := setupTest(t, "POST /api/zones/records/add", "error.json") + client := mockBuilder(). + Route("POST /api/zones/records/add", + servermock.ResponseFromFixture("error.json")). + Build(t) record := Record{ Domain: "_acme-challenge.example.com", @@ -76,7 +68,15 @@ func TestClient_AddRecord_error(t *testing.T) { } func TestClient_DeleteRecord(t *testing.T) { - client := setupTest(t, "POST /api/zones/records/delete", "delete-record.json") + client := mockBuilder(). + Route("POST /api/zones/records/delete", + servermock.ResponseFromFixture("delete-record.json"), + servermock.CheckForm().Strict(). + With("domain", "_acme-challenge.example.com"). + With("text", "txtTXTtxt"). + With("type", "TXT"). + With("token", "secret")). + Build(t) record := Record{ Domain: "_acme-challenge.example.com", @@ -89,7 +89,10 @@ func TestClient_DeleteRecord(t *testing.T) { } func TestClient_DeleteRecord_error(t *testing.T) { - client := setupTest(t, "POST /api/zones/records/delete", "error.json") + client := mockBuilder(). + Route("POST /api/zones/records/delete", + servermock.ResponseFromFixture("error.json")). + Build(t) record := Record{ Domain: "_acme-challenge.example.com", diff --git a/providers/dns/timewebcloud/internal/client_test.go b/providers/dns/timewebcloud/internal/client_test.go index c5a861f68..9d16ba4c5 100644 --- a/providers/dns/timewebcloud/internal/client_test.go +++ b/providers/dns/timewebcloud/internal/client_test.go @@ -1,86 +1,35 @@ package internal import ( - "bytes" - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient(OAuthStaticAccessToken(server.Client(), "secret")) + client.baseURL, _ = url.Parse(server.URL) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := NewClient(OAuthStaticAccessToken(server.Client(), "secret")) - client.baseURL, _ = url.Parse(server.URL) - - return client, mux -} - -func checkAuthorizationHeader(req *http.Request) error { - val := req.Header.Get("Authorization") - if val != "Bearer secret" { - return fmt.Errorf("invalid header value, got: %s want %s", val, "Bearer secret") - } - return nil -} - -func writeResponse(rw http.ResponseWriter, statusCode int, filename string) error { - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - return err - } - - defer func() { _ = file.Close() }() - - rw.WriteHeader(statusCode) - - _, err = io.Copy(rw, file) - if err != nil { - return err - } - - return nil + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("Bearer secret"), + ) } func TestClient_CreateRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("POST /v1/domains/example.com/dns-records", func(rw http.ResponseWriter, req *http.Request) { - err := checkAuthorizationHeader(req) - if err != nil { - http.Error(rw, err.Error(), http.StatusUnauthorized) - return - } - - content, err := io.ReadAll(req.Body) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - if string(bytes.TrimSpace(content)) != `{"type":"TXT","value":"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI","subdomain":"_acme-challenge"}` { - http.Error(rw, "invalid request body: "+string(content), http.StatusBadRequest) - return - } - - err = writeResponse(rw, http.StatusOK, "createDomainDNSRecord.json") - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder(). + Route("POST /v1/domains/example.com/dns-records", + servermock.ResponseFromFixture("createDomainDNSRecord.json"), + servermock.CheckRequestJSONBody(`{"type":"TXT","value":"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI","subdomain":"_acme-challenge"}`)). + Build(t) payload := DNSRecord{ Type: "TXT", @@ -100,15 +49,11 @@ func TestClient_CreateRecord(t *testing.T) { } func TestClient_CreateRecord_error(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("POST /v1/domains/example.com/dns-records", func(rw http.ResponseWriter, _ *http.Request) { - err := writeResponse(rw, http.StatusBadRequest, "error_bad_request.json") - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder(). + Route("POST /v1/domains/example.com/dns-records", + servermock.ResponseFromFixture("error_bad_request.json"). + WithStatusCode(http.StatusBadRequest)). + Build(t) _, err := client.CreateRecord(t.Context(), "example.com.", DNSRecord{}) require.Error(t, err) @@ -117,32 +62,22 @@ func TestClient_CreateRecord_error(t *testing.T) { } func TestClient_DeleteRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("DELETE /v1/domains/example.com/dns-records/123", func(rw http.ResponseWriter, req *http.Request) { - err := checkAuthorizationHeader(req) - if err != nil { - http.Error(rw, err.Error(), http.StatusUnauthorized) - return - } - - rw.WriteHeader(http.StatusNoContent) - }) + client := mockBuilder(). + Route("DELETE /v1/domains/example.com/dns-records/123", + servermock.Noop(). + WithStatusCode(http.StatusNoContent)). + Build(t) err := client.DeleteRecord(t.Context(), "example.com.", 123) require.NoError(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("DELETE /v1/domains/example.com/dns-records/123", func(rw http.ResponseWriter, _ *http.Request) { - err := writeResponse(rw, http.StatusBadRequest, "error_unauthorized.json") - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + client := mockBuilder(). + Route("DELETE /v1/domains/example.com/dns-records/123", + servermock.ResponseFromFixture("error_unauthorized.json"). + WithStatusCode(http.StatusBadRequest)). + Build(t) err := client.DeleteRecord(t.Context(), "example.com.", 123) require.Error(t, err) diff --git a/providers/dns/variomedia/internal/client_test.go b/providers/dns/variomedia/internal/client_test.go index 0daa64f7a..24778bdaf 100644 --- a/providers/dns/variomedia/internal/client_test.go +++ b/providers/dns/variomedia/internal/client_test.go @@ -1,67 +1,37 @@ package internal import ( - "fmt" - "io" - "net/http" "net/http/httptest" "net/url" - "os" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("secret") + client.baseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := NewClient("secret") - client.baseURL, _ = url.Parse(server.URL) - - return client, mux -} - -func mockHandler(method, filename string) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("invalid method, got %s want %s", req.Method, method), http.StatusBadRequest) - return - } - - filename = "./fixtures/" + filename - statusCode := http.StatusOK - - if req.Header.Get(authorizationHeader) != "token secret" { - statusCode = http.StatusUnauthorized - filename = "./fixtures/error.json" - } - - rw.WriteHeader(statusCode) - - file, err := os.Open(filename) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - } + return client, nil + }, + servermock.CheckHeader(). + WithAccept("application/vnd.variomedia.v1+json"). + WithAuthorization("token secret")) } func TestClient_CreateDNSRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/dns-records", mockHandler(http.MethodPost, "POST_dns-records.json")) + client := mockBuilder(). + Route("POST /dns-records", + servermock.ResponseFromFixture("POST_dns-records.json"), + servermock.CheckHeader(). + WithContentType("application/vnd.api+json"), + servermock.CheckRequestJSONBody(`{"data":{"type":"dns-record","attributes":{"record_type":"TXT","name":"_acme-challenge","domain":"example.com","data":"test","ttl":300}}}`)). + Build(t) record := DNSRecord{ RecordType: "TXT", @@ -107,9 +77,10 @@ func TestClient_CreateDNSRecord(t *testing.T) { } func TestClient_DeleteDNSRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/dns-records/test", mockHandler(http.MethodDelete, "DELETE_dns-records_pending.json")) + client := mockBuilder(). + Route("DELETE /dns-records/test", + servermock.ResponseFromFixture("DELETE_dns-records_pending.json")). + Build(t) resp, err := client.DeleteDNSRecord(t.Context(), "test") require.NoError(t, err) @@ -142,9 +113,10 @@ func TestClient_DeleteDNSRecord(t *testing.T) { } func TestClient_GetJob(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/queue-jobs/test", mockHandler(http.MethodGet, "GET_queue-jobs.json")) + client := mockBuilder(). + Route("GET /queue-jobs/test", + servermock.ResponseFromFixture("GET_queue-jobs.json")). + Build(t) resp, err := client.GetJob(t.Context(), "test") require.NoError(t, err) diff --git a/providers/dns/vegadns/fixtures/create_record.json b/providers/dns/vegadns/fixtures/create_record.json new file mode 100644 index 000000000..2199130b9 --- /dev/null +++ b/providers/dns/vegadns/fixtures/create_record.json @@ -0,0 +1,12 @@ +{ + "status": "ok", + "record": { + "name": "_acme-challenge.example.com", + "value": "my_challenge", + "record_type": "TXT", + "ttl": 3600, + "record_id": 3, + "location_id": null, + "domain_id": 1 + } +} diff --git a/providers/dns/vegadns/fixtures/record_delete.json b/providers/dns/vegadns/fixtures/record_delete.json new file mode 100644 index 000000000..bc4e01029 --- /dev/null +++ b/providers/dns/vegadns/fixtures/record_delete.json @@ -0,0 +1,3 @@ +{ + "status": "ok" +} diff --git a/providers/dns/vegadns/fixtures/records.json b/providers/dns/vegadns/fixtures/records.json new file mode 100644 index 000000000..9fa41ce7a --- /dev/null +++ b/providers/dns/vegadns/fixtures/records.json @@ -0,0 +1,43 @@ +{ + "status": "ok", + "total_records": 2, + "domain": { + "status": "active", + "domain": "example.com", + "owner_id": 0, + "domain_id": 1 + }, + "records": [ + { + "retry": "2048", + "minimum": "2560", + "refresh": "16384", + "email": "hostmaster.example.com", + "record_type": "SOA", + "expire": "1048576", + "ttl": 86400, + "record_id": 1, + "nameserver": "ns1.example.com", + "domain_id": 1, + "serial": "" + }, + { + "name": "example.com", + "value": "ns1.example.com", + "record_type": "NS", + "ttl": 3600, + "record_id": 2, + "location_id": null, + "domain_id": 1 + }, + { + "name": "_acme-challenge.example.com", + "value": "my_challenge", + "record_type": "TXT", + "ttl": 3600, + "record_id": 3, + "location_id": null, + "domain_id": 1 + } + ] +} diff --git a/providers/dns/vegadns/fixtures/token.json b/providers/dns/vegadns/fixtures/token.json new file mode 100644 index 000000000..39ab1a4a9 --- /dev/null +++ b/providers/dns/vegadns/fixtures/token.json @@ -0,0 +1,5 @@ +{ + "access_token": "699dd4ff-e381-46b8-8bf8-5de49dd56c1f", + "token_type": "bearer", + "expires_in": 3600 +} diff --git a/providers/dns/vegadns/vegadns_mock_test.go b/providers/dns/vegadns/vegadns_mock_test.go deleted file mode 100644 index 5a705e092..000000000 --- a/providers/dns/vegadns/vegadns_mock_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package vegadns - -const tokenResponseMock = ` -{ - "access_token":"699dd4ff-e381-46b8-8bf8-5de49dd56c1f", - "token_type":"bearer", - "expires_in":3600 -} -` - -const domainsResponseMock = ` -{ - "domains":[ - { - "domain_id":1, - "domain":"example.com", - "status":"active", - "owner_id":0 - } - ] -} -` - -const recordsResponseMock = ` -{ - "status":"ok", - "total_records":2, - "domain":{ - "status":"active", - "domain":"example.com", - "owner_id":0, - "domain_id":1 - }, - "records":[ - { - "retry":"2048", - "minimum":"2560", - "refresh":"16384", - "email":"hostmaster.example.com", - "record_type":"SOA", - "expire":"1048576", - "ttl":86400, - "record_id":1, - "nameserver":"ns1.example.com", - "domain_id":1, - "serial":"" - }, - { - "name":"example.com", - "value":"ns1.example.com", - "record_type":"NS", - "ttl":3600, - "record_id":2, - "location_id":null, - "domain_id":1 - }, - { - "name":"_acme-challenge.example.com", - "value":"my_challenge", - "record_type":"TXT", - "ttl":3600, - "record_id":3, - "location_id":null, - "domain_id":1 - } - ] -} -` - -const recordCreatedResponseMock = ` -{ - "status":"ok", - "record":{ - "name":"_acme-challenge.example.com", - "value":"my_challenge", - "record_type":"TXT", - "ttl":3600, - "record_id":3, - "location_id":null, - "domain_id":1 - } -} -` - -const recordDeletedResponseMock = `{"status": "ok"}` diff --git a/providers/dns/vegadns/vegadns_test.go b/providers/dns/vegadns/vegadns_test.go index 60f614c3b..48f54faab 100644 --- a/providers/dns/vegadns/vegadns_test.go +++ b/providers/dns/vegadns/vegadns_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -28,10 +29,7 @@ func TestDNSProvider_TimeoutSuccess(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() - setupTest(t, muxSuccess()) - - provider, err := NewDNSProvider() - require.NoError(t, err) + provider := mockBuilder().Build(t) timeout, interval := provider.Timeout() assert.Equal(t, 12*time.Minute, timeout) @@ -42,20 +40,38 @@ func TestDNSProvider_Present(t *testing.T) { testCases := []struct { desc string handler http.Handler + builder *servermock.Builder[*DNSProvider] expectedError string }{ { - desc: "Success", - handler: muxSuccess(), + desc: "Success", + builder: mockBuilder(). + Route("POST /1.0/token", + servermock.ResponseFromFixture("token.json")). + Route("GET /1.0/domains", getDomainHandler()). + Route("POST /1.0/records", + servermock.ResponseFromFixture("create_record.json"). + WithStatusCode(http.StatusCreated)), }, { - desc: "FailToFindZone", - handler: muxFailToFindZone(), + desc: "FailToFindZone", + builder: mockBuilder(). + Route("POST /1.0/token", + servermock.ResponseFromFixture("token.json")). + Route("GET /1.0/domains", + servermock.Noop(). + WithStatusCode(http.StatusNotFound)), expectedError: "vegadns: can't find Authoritative Zone for _acme-challenge.example.com. in Present: Unable to find auth zone for fqdn _acme-challenge.example.com", }, { - desc: "FailToCreateTXT", - handler: muxFailToCreateTXT(), + desc: "FailToCreateTXT", + builder: mockBuilder(). + Route("POST /1.0/token", + servermock.ResponseFromFixture("token.json")). + Route("GET /1.0/domains", getDomainHandler()). + Route("POST /1.0/records", + servermock.Noop(). + WithStatusCode(http.StatusBadRequest)), expectedError: "vegadns: Got bad answer from VegaDNS on CreateTXT. Code: 400. Message: ", }, } @@ -65,12 +81,9 @@ func TestDNSProvider_Present(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() - setupTest(t, test.handler) + provider := test.builder.Build(t) - provider, err := NewDNSProvider() - require.NoError(t, err) - - err = provider.Present(testDomain, "token", "keyAuth") + err := provider.Present(testDomain, "token", "keyAuth") if test.expectedError == "" { assert.NoError(t, err) } else { @@ -83,21 +96,41 @@ func TestDNSProvider_Present(t *testing.T) { func TestDNSProvider_CleanUp(t *testing.T) { testCases := []struct { desc string - handler http.Handler + builder *servermock.Builder[*DNSProvider] expectedError string }{ { - desc: "Success", - handler: muxSuccess(), + desc: "Success", + builder: mockBuilder(). + Route("POST /1.0/token", + servermock.ResponseFromFixture("token.json")). + Route("GET /1.0/domains", getDomainHandler()). + Route("GET /1.0/records", + servermock.ResponseFromFixture("records.json"), + servermock.CheckQueryParameter().With("domain_id", "1")). + Route("DELETE /1.0/records/3", + servermock.ResponseFromFixture("record_delete.json")), }, { - desc: "FailToFindZone", - handler: muxFailToFindZone(), + desc: "FailToFindZone", + builder: mockBuilder(). + Route("POST /1.0/token", + servermock.ResponseFromFixture("token.json")). + Route("GET /1.0/domains", + servermock.Noop(). + WithStatusCode(http.StatusNotFound)), expectedError: "vegadns: can't find Authoritative Zone for _acme-challenge.example.com. in CleanUp: Unable to find auth zone for fqdn _acme-challenge.example.com", }, { - desc: "FailToGetRecordID", - handler: muxFailToGetRecordID(), + desc: "FailToGetRecordID", + builder: mockBuilder(). + Route("POST /1.0/token", + servermock.ResponseFromFixture("token.json")). + Route("GET /1.0/domains", getDomainHandler()). + Route("GET /1.0/records", + servermock.Noop(). + WithStatusCode(http.StatusNotFound), + servermock.CheckQueryParameter().With("domain_id", "1")), expectedError: "vegadns: couldn't get Record ID in CleanUp: Got bad answer from VegaDNS on GetRecordID. Code: 404. Message: ", }, } @@ -107,12 +140,9 @@ func TestDNSProvider_CleanUp(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() - setupTest(t, test.handler) + provider := test.builder.Build(t) - provider, err := NewDNSProvider() - require.NoError(t, err) - - err = provider.CleanUp(testDomain, "token", "keyAuth") + err := provider.CleanUp(testDomain, "token", "keyAuth") if test.expectedError == "" { assert.NoError(t, err) } else { @@ -122,163 +152,36 @@ func TestDNSProvider_CleanUp(t *testing.T) { } } -func muxSuccess() *http.ServeMux { - mux := http.NewServeMux() - - mux.HandleFunc("/1.0/token", func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodPost { - w.WriteHeader(http.StatusOK) - fmt.Fprint(w, tokenResponseMock) +func getDomainHandler() http.HandlerFunc { + return func(rw http.ResponseWriter, req *http.Request) { + if req.URL.Query().Get("search") == testDomain { + fmt.Fprint(rw, ` +{ + "domains":[ + { + "domain_id":1, + "domain":"example.com", + "status":"active", + "owner_id":0 + } + ] +} +`) return } - w.WriteHeader(http.StatusBadRequest) - }) - mux.HandleFunc("/1.0/domains", func(w http.ResponseWriter, r *http.Request) { - if r.URL.Query().Get("search") == "example.com" { - w.WriteHeader(http.StatusOK) - fmt.Fprint(w, domainsResponseMock) - return - } - w.WriteHeader(http.StatusNotFound) - }) - - mux.HandleFunc("/1.0/records", func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - if r.URL.Query().Get("domain_id") == "1" { - w.WriteHeader(http.StatusOK) - fmt.Fprint(w, recordsResponseMock) - return - } - w.WriteHeader(http.StatusNotFound) - return - case http.MethodPost: - w.WriteHeader(http.StatusCreated) - fmt.Fprint(w, recordCreatedResponseMock) - return - } - w.WriteHeader(http.StatusBadRequest) - }) - - mux.HandleFunc("/1.0/records/3", func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodDelete { - w.WriteHeader(http.StatusOK) - fmt.Fprint(w, recordDeletedResponseMock) - return - } - w.WriteHeader(http.StatusBadRequest) - }) - - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotFound) - fmt.Printf("Not Found for Request: (%+v)\n\n", r) - }) - - return mux + rw.WriteHeader(http.StatusNotFound) + } } -func muxFailToFindZone() *http.ServeMux { - mux := http.NewServeMux() +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder(func(server *httptest.Server) (*DNSProvider, error) { + envTest.Apply(map[string]string{ + EnvKey: "key", + EnvSecret: "secret", + EnvURL: server.URL, + }) - mux.HandleFunc("/1.0/token", func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodPost { - w.WriteHeader(http.StatusOK) - fmt.Fprint(w, tokenResponseMock) - return - } - w.WriteHeader(http.StatusBadRequest) - }) - - mux.HandleFunc("/1.0/domains", func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - }) - - return mux -} - -func muxFailToCreateTXT() *http.ServeMux { - mux := http.NewServeMux() - - mux.HandleFunc("/1.0/token", func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodPost { - w.WriteHeader(http.StatusOK) - fmt.Fprint(w, tokenResponseMock) - return - } - w.WriteHeader(http.StatusBadRequest) - }) - - mux.HandleFunc("/1.0/domains", func(w http.ResponseWriter, r *http.Request) { - if r.URL.Query().Get("search") == testDomain { - w.WriteHeader(http.StatusOK) - fmt.Fprint(w, domainsResponseMock) - return - } - w.WriteHeader(http.StatusNotFound) - }) - - mux.HandleFunc("/1.0/records", func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - if r.URL.Query().Get("domain_id") == "1" { - w.WriteHeader(http.StatusOK) - fmt.Fprint(w, recordsResponseMock) - return - } - w.WriteHeader(http.StatusNotFound) - return - case http.MethodPost: - w.WriteHeader(http.StatusBadRequest) - return - } - w.WriteHeader(http.StatusBadRequest) - }) - - return mux -} - -func muxFailToGetRecordID() *http.ServeMux { - mux := http.NewServeMux() - - mux.HandleFunc("/1.0/token", func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodPost { - w.WriteHeader(http.StatusOK) - fmt.Fprint(w, tokenResponseMock) - return - } - w.WriteHeader(http.StatusBadRequest) - }) - - mux.HandleFunc("/1.0/domains", func(w http.ResponseWriter, r *http.Request) { - if r.URL.Query().Get("search") == testDomain { - w.WriteHeader(http.StatusOK) - fmt.Fprint(w, domainsResponseMock) - return - } - w.WriteHeader(http.StatusNotFound) - }) - - mux.HandleFunc("/1.0/records", func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet { - w.WriteHeader(http.StatusNotFound) - return - } - w.WriteHeader(http.StatusBadRequest) - }) - - return mux -} - -func setupTest(t *testing.T, mux http.Handler) { - t.Helper() - - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - envTest.Apply(map[string]string{ - EnvKey: "key", - EnvSecret: "secret", - EnvURL: server.URL, + return NewDNSProvider() }) } diff --git a/providers/dns/vercel/internal/client_test.go b/providers/dns/vercel/internal/client_test.go index 2a8b4eaea..eb5ee501d 100644 --- a/providers/dns/vercel/internal/client_test.go +++ b/providers/dns/vercel/internal/client_test.go @@ -1,71 +1,38 @@ package internal import ( - "bytes" - "fmt" - "io" - "net/http" "net/http/httptest" "net/url" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient(OAuthStaticAccessToken(server.Client(), "secret"), "123") + client.baseURL, _ = url.Parse(server.URL) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := NewClient(OAuthStaticAccessToken(server.Client(), "secret"), "123") - client.baseURL, _ = url.Parse(server.URL) - - return client, mux + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("Bearer secret")) } func TestClient_CreateRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/v2/domains/example.com/records", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - - auth := req.Header.Get("Authorization") - if auth != "Bearer secret" { - http.Error(rw, fmt.Sprintf("invalid API token: %s", auth), http.StatusUnauthorized) - return - } - - teamID := req.URL.Query().Get("teamId") - if teamID != "123" { - http.Error(rw, fmt.Sprintf("invalid team ID: %s", teamID), http.StatusUnauthorized) - return - } - - reqBody, err := io.ReadAll(req.Body) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - expectedReqBody := `{"name":"_acme-challenge.example.com.","type":"TXT","value":"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI","ttl":60}` - assert.Equal(t, expectedReqBody, string(bytes.TrimSpace(reqBody))) - - rw.WriteHeader(http.StatusOK) - _, err = fmt.Fprintf(rw, `{ + client := mockBuilder(). + Route("POST /v2/domains/example.com/records", + servermock.RawStringResponse(`{ "uid": "9e2eab60-0ba5-4dff-b481-2999c9764b84", "updated": 1 - }`) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + }`), + servermock.CheckRequestJSONBody(`{"name":"_acme-challenge.example.com.","type":"TXT","value":"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI","ttl":60}`), + servermock.CheckQueryParameter().Strict(). + With("teamId", "123")). + Build(t) record := Record{ Name: "_acme-challenge.example.com.", @@ -86,27 +53,11 @@ func TestClient_CreateRecord(t *testing.T) { } func TestClient_DeleteRecord(t *testing.T) { - client, mux := setupTest(t) - - mux.HandleFunc("/v2/domains/example.com/records/1234567", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodDelete { - http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) - return - } - auth := req.Header.Get("Authorization") - if auth != "Bearer secret" { - http.Error(rw, fmt.Sprintf("invalid API token: %s", auth), http.StatusUnauthorized) - return - } - - teamID := req.URL.Query().Get("teamId") - if teamID != "123" { - http.Error(rw, fmt.Sprintf("invalid team ID: %s", teamID), http.StatusUnauthorized) - return - } - - rw.WriteHeader(http.StatusOK) - }) + client := mockBuilder(). + Route("DELETE /v2/domains/example.com/records/1234567", nil, + servermock.CheckQueryParameter().Strict(). + With("teamId", "123")). + Build(t) err := client.DeleteRecord(t.Context(), "example.com.", "1234567") require.NoError(t, err) diff --git a/providers/dns/versio/fixtures/error_failToCreateTXT.json b/providers/dns/versio/fixtures/error_failToCreateTXT.json new file mode 100644 index 000000000..1e1784517 --- /dev/null +++ b/providers/dns/versio/fixtures/error_failToCreateTXT.json @@ -0,0 +1,6 @@ +{ + "error": { + "code": 400, + "message": "ProcessError|DNS record invalid type _acme-challenge.example.eu. TST" + } +} diff --git a/providers/dns/versio/fixtures/error_failToFindZone.json b/providers/dns/versio/fixtures/error_failToFindZone.json new file mode 100644 index 000000000..635b2bda1 --- /dev/null +++ b/providers/dns/versio/fixtures/error_failToFindZone.json @@ -0,0 +1,6 @@ +{ + "error": { + "code": 401, + "message": "ObjectDoesNotExist|Domain not found" + } +} diff --git a/providers/dns/versio/fixtures/token.json b/providers/dns/versio/fixtures/token.json new file mode 100644 index 000000000..0dc0dda25 --- /dev/null +++ b/providers/dns/versio/fixtures/token.json @@ -0,0 +1,5 @@ +{ + "access_token":"699dd4ff-e381-46b8-8bf8-5de49dd56c1f", + "token_type":"bearer", + "expires_in":3600 +} diff --git a/providers/dns/versio/internal/client_test.go b/providers/dns/versio/internal/client_test.go index 63b80ce4a..f3bf68c6d 100644 --- a/providers/dns/versio/internal/client_test.go +++ b/providers/dns/versio/internal/client_test.go @@ -1,61 +1,36 @@ package internal import ( - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, pattern string, h http.HandlerFunc) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("user", "secret") + client.HTTPClient = server.Client() + client.BaseURL, _ = url.Parse(server.URL) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc(pattern, h) - - client := NewClient("user", "secret") - client.HTTPClient = server.Client() - client.BaseURL, _ = url.Parse(server.URL) - - return client -} - -func writeFixture(rw http.ResponseWriter, filename string) { - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, _ = io.Copy(rw, file) + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithBasicAuth("user", "secret")) } func TestClient_GetDomain(t *testing.T) { - client := setupTest(t, "/domains/example.com", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - auth := req.Header.Get("Authorization") - if auth != "Basic dXNlcjpzZWNyZXQ=" { - http.Error(rw, "invalid credentials: "+auth, http.StatusUnauthorized) - return - } - - writeFixture(rw, "get-domain.json") - }) + client := mockBuilder(). + Route("GET /domains/example.com", + servermock.ResponseFromFixture("get-domain.json"), + servermock.CheckQueryParameter().Strict(). + With("show_dns_records", "true")). + Build(t) records, err := client.GetDomain(t.Context(), "example.com") require.NoError(t, err) @@ -79,36 +54,22 @@ func TestClient_GetDomain(t *testing.T) { } func TestClient_GetDomain_error(t *testing.T) { - client := setupTest(t, "/domains/example.com", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - rw.WriteHeader(http.StatusUnauthorized) - - writeFixture(rw, "get-domain-error.json") - }) + client := mockBuilder(). + Route("GET /domains/example.com", + servermock.ResponseFromFixture("get-domain-error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) _, err := client.GetDomain(t.Context(), "example.com") require.ErrorAs(t, err, &ErrorMessage{}) } func TestClient_UpdateDomain(t *testing.T) { - client := setupTest(t, "/domains/example.com/update", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - auth := req.Header.Get("Authorization") - if auth != "Basic dXNlcjpzZWNyZXQ=" { - http.Error(rw, "invalid credentials: "+auth, http.StatusUnauthorized) - return - } - - writeFixture(rw, "update-domain.json") - }) + client := mockBuilder(). + Route("POST /domains/example.com/update", + servermock.ResponseFromFixture("update-domain.json"), + servermock.CheckRequestJSONBodyFromFile("update-domain-request.json")). + Build(t) msg := &DomainInfo{DNSRecords: []Record{ {Type: "MX", Name: "example.com", Value: "fallback.axc.eu", Priority: 20, TTL: 3600}, @@ -147,16 +108,11 @@ func TestClient_UpdateDomain(t *testing.T) { } func TestClient_UpdateDomain_error(t *testing.T) { - client := setupTest(t, "/domains/example.com/update", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - rw.WriteHeader(http.StatusUnauthorized) - - writeFixture(rw, "update-domain.json") - }) + client := mockBuilder(). + Route("POST /domains/example.com/update", + servermock.ResponseFromFixture("update-domain-error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) msg := &DomainInfo{DNSRecords: []Record{ {Type: "MX", Name: "example.com", Value: "fallback.axc.eu", Priority: 20, TTL: 3600}, diff --git a/providers/dns/versio/internal/fixtures/update-domain-request.json b/providers/dns/versio/internal/fixtures/update-domain-request.json new file mode 100644 index 000000000..f351678fc --- /dev/null +++ b/providers/dns/versio/internal/fixtures/update-domain-request.json @@ -0,0 +1,78 @@ +{ + "dns_records": [ + { + "type": "MX", + "name": "example.com", + "value": "fallback.axc.eu", + "prio": 20, + "ttl": 3600 + }, + { + "type": "TXT", + "name": "example.com", + "value": "\"v=spf1 a mx ip4:127.0.0.1 a:spf.spamexperts.axc.nl ~all\"", + "ttl": 3600 + }, + { + "type": "A", + "name": "example.com", + "value": "185.13.227.159", + "ttl": 14400 + }, + { + "type": "A", + "name": "ftp.example.com", + "value": "185.13.227.159", + "ttl": 14400 + }, + { + "type": "A", + "name": "localhost.example.com", + "value": "185.13.227.159", + "ttl": 14400 + }, + { + "type": "A", + "name": "pop.example.com", + "value": "185.13.227.159", + "ttl": 14400 + }, + { + "type": "A", + "name": "smtp.example.com", + "value": "185.13.227.159", + "ttl": 14400 + }, + { + "type": "A", + "name": "www.example.com", + "value": "185.13.227.159", + "ttl": 14400 + }, + { + "type": "A", + "name": "dev.example.com", + "value": "185.13.227.159", + "ttl": 14400 + }, + { + "type": "A", + "name": "_domainkey.domain.com.example.com", + "value": "185.13.227.159", + "ttl": 14400 + }, + { + "type": "MX", + "name": "example.com", + "value": "spamfilter2.axc.eu", + "ttl": 3600 + }, + { + "type": "A", + "name": "redirect.example.com", + "value": "localhost", + "prio": 10, + "ttl": 14400 + } + ] +} diff --git a/providers/dns/versio/versio_mock_test.go b/providers/dns/versio/versio_mock_test.go deleted file mode 100644 index 07dc74e83..000000000 --- a/providers/dns/versio/versio_mock_test.go +++ /dev/null @@ -1,13 +0,0 @@ -package versio - -const tokenResponseMock = ` -{ - "access_token":"699dd4ff-e381-46b8-8bf8-5de49dd56c1f", - "token_type":"bearer", - "expires_in":3600 -} -` - -const tokenFailToFindZoneMock = `{"error":{"code":401,"message":"ObjectDoesNotExist|Domain not found"}}` - -const tokenFailToCreateTXTMock = `{"error":{"code":400,"message":"ProcessError|DNS record invalid type _acme-challenge.example.eu. TST"}}` diff --git a/providers/dns/versio/versio_test.go b/providers/dns/versio/versio_test.go index 09040ab4c..ea1ccc221 100644 --- a/providers/dns/versio/versio_test.go +++ b/providers/dns/versio/versio_test.go @@ -1,14 +1,12 @@ package versio import ( - "fmt" - "io" "net/http" "net/http/httptest" "testing" - "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -125,21 +123,37 @@ func TestNewDNSProviderConfig(t *testing.T) { func TestDNSProvider_Present(t *testing.T) { testCases := []struct { desc string - handler http.Handler + builder *servermock.Builder[*DNSProvider] expectedError string }{ { - desc: "Success", - handler: muxSuccess(), + desc: "Success", + builder: mockBuilder(). + Route("GET /domains/example.com", + servermock.ResponseFromFixture("token.json"), + servermock.CheckQueryParameter().Strict(). + With("show_dns_records", "true")). + Route("POST /domains/example.com/update", + servermock.ResponseFromFixture("token.json")), }, { - desc: "FailToFindZone", - handler: muxFailToFindZone(), + desc: "FailToFindZone", + builder: mockBuilder(). + Route("GET /domains/example.com", + servermock.ResponseFromFixture("error_failToFindZone.json"). + WithStatusCode(http.StatusUnauthorized)), expectedError: `versio: [status code: 401] 401: ObjectDoesNotExist|Domain not found`, }, { - desc: "FailToCreateTXT", - handler: muxFailToCreateTXT(), + desc: "FailToCreateTXT", + builder: mockBuilder(). + Route("GET /domains/example.com", + servermock.ResponseFromFixture("token.json"), + servermock.CheckQueryParameter().Strict(). + With("show_dns_records", "true")). + Route("POST /domains/example.com/update", + servermock.ResponseFromFixture("error_failToCreateTXT.json"). + WithStatusCode(http.StatusBadRequest)), expectedError: `versio: [status code: 400] 400: ProcessError|DNS record invalid type _acme-challenge.example.eu. TST`, }, } @@ -149,17 +163,9 @@ func TestDNSProvider_Present(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() - baseURL := setupTest(t, test.handler) + provider := test.builder.Build(t) - envTest.Apply(map[string]string{ - EnvUsername: "me@example.com", - EnvPassword: "secret", - EnvEndpoint: baseURL, - }) - provider, err := NewDNSProvider() - require.NoError(t, err) - - err = provider.Present(testDomain, "token", "keyAuth") + err := provider.Present(testDomain, "token", "keyAuth") if test.expectedError == "" { require.NoError(t, err) } else { @@ -172,16 +178,25 @@ func TestDNSProvider_Present(t *testing.T) { func TestDNSProvider_CleanUp(t *testing.T) { testCases := []struct { desc string - handler http.Handler + builder *servermock.Builder[*DNSProvider] expectedError string }{ { - desc: "Success", - handler: muxSuccess(), + desc: "Success", + builder: mockBuilder(). + Route("GET /domains/example.com", + servermock.ResponseFromFixture("token.json"), + servermock.CheckQueryParameter().Strict(). + With("show_dns_records", "true")). + Route("POST /domains/example.com/update", + servermock.ResponseFromFixture("token.json")), }, { - desc: "FailToFindZone", - handler: muxFailToFindZone(), + desc: "FailToFindZone", + builder: mockBuilder(). + Route("GET /domains/example.com", + servermock.ResponseFromFixture("error_failToFindZone.json"). + WithStatusCode(http.StatusUnauthorized)), expectedError: `versio: [status code: 401] 401: ObjectDoesNotExist|Domain not found`, }, } @@ -191,18 +206,9 @@ func TestDNSProvider_CleanUp(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() - baseURL := setupTest(t, test.handler) + provider := test.builder.Build(t) - envTest.Apply(map[string]string{ - EnvUsername: "me@example.com", - EnvPassword: "secret", - EnvEndpoint: baseURL, - }) - - provider, err := NewDNSProvider() - require.NoError(t, err) - - err = provider.CleanUp(testDomain, "token", "keyAuth") + err := provider.CleanUp(testDomain, "token", "keyAuth") if test.expectedError == "" { require.NoError(t, err) } else { @@ -212,85 +218,6 @@ func TestDNSProvider_CleanUp(t *testing.T) { } } -func muxSuccess() *http.ServeMux { - mux := http.NewServeMux() - - mux.HandleFunc("/domains/example.com", func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet && r.URL.Query().Get("show_dns_records") == "true" { - fmt.Fprint(w, tokenResponseMock) - return - } - w.WriteHeader(http.StatusBadRequest) - }) - - mux.HandleFunc("/domains/example.com/update", func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodPost { - fmt.Fprint(w, tokenResponseMock) - return - } - w.WriteHeader(http.StatusBadRequest) - }) - - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - log.Printf("unexpected request: %+v\n\n", r) - data, _ := io.ReadAll(r.Body) - defer func() { _ = r.Body.Close() }() - log.Println(string(data)) - http.NotFound(w, r) - }) - - return mux -} - -func muxFailToFindZone() *http.ServeMux { - mux := http.NewServeMux() - - mux.HandleFunc("/domains/example.com", func(w http.ResponseWriter, _ *http.Request) { - http.Error(w, tokenFailToFindZoneMock, http.StatusUnauthorized) - }) - - return mux -} - -func muxFailToCreateTXT() *http.ServeMux { - mux := http.NewServeMux() - - mux.HandleFunc("/domains/example.com", func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet && r.URL.Query().Get("show_dns_records") == "true" { - fmt.Fprint(w, tokenResponseMock) - return - } - w.WriteHeader(http.StatusBadRequest) - }) - - mux.HandleFunc("/domains/example.com/update", func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodPost { - http.Error(w, tokenFailToCreateTXTMock, http.StatusBadRequest) - return - } - w.WriteHeader(http.StatusBadRequest) - }) - - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - log.Printf("unexpected request: %+v\n\n", r) - data, _ := io.ReadAll(r.Body) - defer func() { _ = r.Body.Close() }() - log.Println(string(data)) - http.NotFound(w, r) - }) - - return mux -} - -func setupTest(t *testing.T, handler http.Handler) string { - t.Helper() - - server := httptest.NewServer(handler) - t.Cleanup(server.Close) - - return server.URL -} - func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") @@ -316,3 +243,15 @@ func TestLiveCleanUp(t *testing.T) { err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } + +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder(func(server *httptest.Server) (*DNSProvider, error) { + envTest.Apply(map[string]string{ + EnvUsername: "me@example.com", + EnvPassword: "secret", + EnvEndpoint: server.URL, + }) + + return NewDNSProvider() + }) +} diff --git a/providers/dns/vinyldns/mock_test.go b/providers/dns/vinyldns/mock_test.go deleted file mode 100644 index 54fd8e214..000000000 --- a/providers/dns/vinyldns/mock_test.go +++ /dev/null @@ -1,114 +0,0 @@ -package vinyldns - -import ( - "fmt" - "net/http" - "net/http/httptest" - "os" - "sync" - "testing" - - "github.com/stretchr/testify/require" -) - -func setupTest(t *testing.T) (*http.ServeMux, *DNSProvider) { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - config := NewDefaultConfig() - config.AccessKey = "foo" - config.SecretKey = "bar" - config.Host = server.URL - - p, err := NewDNSProviderConfig(config) - require.NoError(t, err) - - return mux, p -} - -type mockRouter struct { - debug bool - - mu sync.Mutex - routes map[string]map[string]http.HandlerFunc -} - -func newMockRouter() *mockRouter { - routes := map[string]map[string]http.HandlerFunc{ - http.MethodGet: {}, - http.MethodPost: {}, - http.MethodPut: {}, - http.MethodDelete: {}, - } - - return &mockRouter{ - routes: routes, - } -} - -func (h *mockRouter) Debug() *mockRouter { - h.debug = true - - return h -} - -func (h *mockRouter) Get(path string, statusCode int, filename string) *mockRouter { - h.add(http.MethodGet, path, statusCode, filename) - return h -} - -func (h *mockRouter) Post(path string, statusCode int, filename string) *mockRouter { - h.add(http.MethodPost, path, statusCode, filename) - return h -} - -func (h *mockRouter) Put(path string, statusCode int, filename string) *mockRouter { - h.add(http.MethodPut, path, statusCode, filename) - return h -} - -func (h *mockRouter) Delete(path string, statusCode int, filename string) *mockRouter { - h.add(http.MethodDelete, path, statusCode, filename) - return h -} - -func (h *mockRouter) ServeHTTP(rw http.ResponseWriter, req *http.Request) { - h.mu.Lock() - defer h.mu.Unlock() - - if h.debug { - fmt.Println(req) - } - - rt := h.routes[req.Method] - if rt == nil { - http.NotFound(rw, req) - return - } - - hdl := rt[req.URL.Path] - if hdl == nil { - http.NotFound(rw, req) - return - } - - hdl(rw, req) -} - -func (h *mockRouter) add(method, path string, statusCode int, filename string) { - h.routes[method][path] = func(rw http.ResponseWriter, req *http.Request) { - rw.WriteHeader(statusCode) - - data, err := os.ReadFile(fmt.Sprintf("./fixtures/%s.json", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - rw.Header().Set("Content-Type", "application/json") - _, _ = rw.Write(data) - } -} diff --git a/providers/dns/vinyldns/vinyldns_test.go b/providers/dns/vinyldns/vinyldns_test.go index 8bfb192c8..6f5b9b328 100644 --- a/providers/dns/vinyldns/vinyldns_test.go +++ b/providers/dns/vinyldns/vinyldns_test.go @@ -2,10 +2,12 @@ package vinyldns import ( "net/http" + "net/http/httptest" "testing" "time" "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) @@ -154,63 +156,86 @@ func TestNewDNSProviderConfig(t *testing.T) { } } +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder(func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.AccessKey = "foo" + config.SecretKey = "bar" + config.Host = server.URL + + return NewDNSProviderConfig(config) + }) +} + func TestDNSProvider_Present(t *testing.T) { testCases := []struct { desc string keyAuth string - handler http.Handler + builder *servermock.Builder[*DNSProvider] }{ { desc: "new record", keyAuth: "123456d==", - handler: newMockRouter(). - Get("/zones/name/"+targetRootDomain+".", http.StatusOK, "zoneByName"). - Get("/zones/"+zoneID+"/recordsets", http.StatusOK, "recordSetsListAll-empty"). - Post("/zones/"+zoneID+"/recordsets", http.StatusAccepted, "recordSetUpdate-create"). - Get("/zones/"+zoneID+"/recordsets/"+newRecordSetID+"/changes/"+newCreateChangeID, http.StatusOK, "recordSetChange-create"), + builder: mockBuilder(). + Route("GET /zones/name/"+targetRootDomain+".", + servermock.ResponseFromFixture("zoneByName.json")). + Route("GET /zones/"+zoneID+"/recordsets", + servermock.ResponseFromFixture("recordSetsListAll-empty.json")). + Route("POST /zones/"+zoneID+"/recordsets", + servermock.ResponseFromFixture("recordSetUpdate-create.json"). + WithStatusCode(http.StatusAccepted)). + Route("GET /zones/"+zoneID+"/recordsets/"+newRecordSetID+"/changes/"+newCreateChangeID, + servermock.ResponseFromFixture("recordSetChange-create.json")), }, { desc: "existing record", keyAuth: "123456d==", - handler: newMockRouter(). - Get("/zones/name/"+targetRootDomain+".", http.StatusOK, "zoneByName"). - Get("/zones/"+zoneID+"/recordsets", http.StatusOK, "recordSetsListAll"), + builder: mockBuilder(). + Route("GET /zones/name/"+targetRootDomain+".", + servermock.ResponseFromFixture("zoneByName.json")). + Route("GET /zones/"+zoneID+"/recordsets", + servermock.ResponseFromFixture("recordSetsListAll.json")), }, { desc: "duplicate key", keyAuth: "abc123!!", - handler: newMockRouter(). - Get("/zones/name/"+targetRootDomain+".", http.StatusOK, "zoneByName"). - Get("/zones/"+zoneID+"/recordsets", http.StatusOK, "recordSetsListAll"). - Put("/zones/"+zoneID+"/recordsets/"+recordID, http.StatusAccepted, "recordSetUpdate-create"). - Get("/zones/"+zoneID+"/recordsets/"+newRecordSetID+"/changes/"+newCreateChangeID, http.StatusOK, "recordSetChange-create"), + builder: mockBuilder(). + Route("GET /zones/name/"+targetRootDomain+".", + servermock.ResponseFromFixture("zoneByName.json")). + Route("GET /zones/"+zoneID+"/recordsets", + servermock.ResponseFromFixture("recordSetsListAll.json")). + Route("PUT /zones/"+zoneID+"/recordsets/"+recordID, + servermock.ResponseFromFixture("recordSetUpdate-create.json"). + WithStatusCode(http.StatusAccepted)). + Route("GET /zones/"+zoneID+"/recordsets/"+newRecordSetID+"/changes/"+newCreateChangeID, + servermock.ResponseFromFixture("recordSetChange-create.json")), }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - t.Parallel() + provider := test.builder.Build(t) - mux, p := setupTest(t) - mux.Handle("/", test.handler) - - err := p.Present(targetDomain, "token"+test.keyAuth, test.keyAuth) + err := provider.Present(targetDomain, "token"+test.keyAuth, test.keyAuth) require.NoError(t, err) }) } } func TestDNSProvider_CleanUp(t *testing.T) { - mux, p := setupTest(t) + provider := mockBuilder(). + Route("GET /zones/name/"+targetRootDomain+".", + servermock.ResponseFromFixture("zoneByName.json")). + Route("GET /zones/"+zoneID+"/recordsets", + servermock.ResponseFromFixture("recordSetsListAll.json")). + Route("DELETE /zones/"+zoneID+"/recordsets/"+recordID, + servermock.ResponseFromFixture("recordSetDelete.json"). + WithStatusCode(http.StatusAccepted)). + Route("GET /zones/"+zoneID+"/recordsets/"+newRecordSetID+"/changes/"+newCreateChangeID, + servermock.ResponseFromFixture("recordSetChange-delete.json")). + Build(t) - mux.Handle("/", newMockRouter(). - Get("/zones/name/"+targetRootDomain+".", http.StatusOK, "zoneByName"). - Get("/zones/"+zoneID+"/recordsets", http.StatusOK, "recordSetsListAll"). - Delete("/zones/"+zoneID+"/recordsets/"+recordID, http.StatusAccepted, "recordSetDelete"). - Get("/zones/"+zoneID+"/recordsets/"+newRecordSetID+"/changes/"+newCreateChangeID, http.StatusOK, "recordSetChange-delete"), - ) - - err := p.CleanUp(targetDomain, "123456d==", "123456d==") + err := provider.CleanUp(targetDomain, "123456d==", "123456d==") require.NoError(t, err) } diff --git a/providers/dns/vkcloud/vkcloud.go b/providers/dns/vkcloud/vkcloud.go index e76e87137..2aea7838c 100644 --- a/providers/dns/vkcloud/vkcloud.go +++ b/providers/dns/vkcloud/vkcloud.go @@ -119,7 +119,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { } // Present creates a TXT record to fulfill the dns-01 challenge. -func (r *DNSProvider) Present(domain, _, keyAuth string) error { +func (d *DNSProvider) Present(domain, _, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) @@ -129,7 +129,7 @@ func (r *DNSProvider) Present(domain, _, keyAuth string) error { authZone = dns01.UnFqdn(authZone) - zones, err := r.client.ListZones() + zones, err := d.client.ListZones() if err != nil { return fmt.Errorf("vkcloud: unable to fetch dns zones: %w", err) } @@ -150,7 +150,7 @@ func (r *DNSProvider) Present(domain, _, keyAuth string) error { return fmt.Errorf("vkcloud: %w", err) } - err = r.upsertTXTRecord(zoneUUID, subDomain, info.Value) + err = d.upsertTXTRecord(zoneUUID, subDomain, info.Value) if err != nil { return fmt.Errorf("vkcloud: %w", err) } @@ -159,7 +159,7 @@ func (r *DNSProvider) Present(domain, _, keyAuth string) error { } // CleanUp removes the TXT record matching the specified parameters. -func (r *DNSProvider) CleanUp(domain, _, keyAuth string) error { +func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) @@ -169,7 +169,7 @@ func (r *DNSProvider) CleanUp(domain, _, keyAuth string) error { authZone = dns01.UnFqdn(authZone) - zones, err := r.client.ListZones() + zones, err := d.client.ListZones() if err != nil { return fmt.Errorf("vkcloud: unable to fetch dns zones: %w", err) } @@ -191,7 +191,7 @@ func (r *DNSProvider) CleanUp(domain, _, keyAuth string) error { return fmt.Errorf("vkcloud: %w", err) } - err = r.removeTXTRecord(zoneUUID, subDomain, info.Value) + err = d.removeTXTRecord(zoneUUID, subDomain, info.Value) if err != nil { return fmt.Errorf("vkcloud: %w", err) } @@ -201,12 +201,12 @@ func (r *DNSProvider) CleanUp(domain, _, keyAuth string) error { // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. -func (r *DNSProvider) Timeout() (timeout, interval time.Duration) { - return r.config.PropagationTimeout, r.config.PollingInterval +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval } -func (r *DNSProvider) upsertTXTRecord(zoneUUID, name, value string) error { - records, err := r.client.ListTXTRecords(zoneUUID) +func (d *DNSProvider) upsertTXTRecord(zoneUUID, name, value string) error { + records, err := d.client.ListTXTRecords(zoneUUID) if err != nil { return err } @@ -218,15 +218,15 @@ func (r *DNSProvider) upsertTXTRecord(zoneUUID, name, value string) error { } } - return r.client.CreateTXTRecord(zoneUUID, &internal.DNSTXTRecord{ + return d.client.CreateTXTRecord(zoneUUID, &internal.DNSTXTRecord{ Name: name, Content: value, - TTL: r.config.TTL, + TTL: d.config.TTL, }) } -func (r *DNSProvider) removeTXTRecord(zoneUUID, name, value string) error { - records, err := r.client.ListTXTRecords(zoneUUID) +func (d *DNSProvider) removeTXTRecord(zoneUUID, name, value string) error { + records, err := d.client.ListTXTRecords(zoneUUID) if err != nil { return err } @@ -234,7 +234,7 @@ func (r *DNSProvider) removeTXTRecord(zoneUUID, name, value string) error { name = dns01.UnFqdn(name) for _, record := range records { if record.Name == name && record.Content == value { - return r.client.DeleteTXTRecord(zoneUUID, record.UUID) + return d.client.DeleteTXTRecord(zoneUUID, record.UUID) } } diff --git a/providers/dns/vultr/vultr_test.go b/providers/dns/vultr/vultr_test.go index aed891628..9be1a19b0 100644 --- a/providers/dns/vultr/vultr_test.go +++ b/providers/dns/vultr/vultr_test.go @@ -10,6 +10,7 @@ import ( "time" "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/vultr/govultr/v3" @@ -159,53 +160,53 @@ func TestDNSProvider_getHostedZone(t *testing.T) { t.Run(test.desc, func(t *testing.T) { t.Parallel() - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - client := govultr.NewClient(nil) - err := client.SetBaseURL(server.URL) - require.NoError(t, err) - - p := &DNSProvider{client: client} - var pageCount int - mux.HandleFunc("/v2/domains", func(rw http.ResponseWriter, req *http.Request) { - pageCount++ + provider := servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + client := govultr.NewClient(nil) + err := client.SetBaseURL(server.URL) + require.NoError(t, err) - query := req.URL.Query() - cursor, _ := strconv.Atoi(query.Get("cursor")) - perPage, _ := strconv.Atoi(query.Get("per_page")) + return &DNSProvider{client: client}, nil + }, + ). + Route("GET /v2/domains", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + pageCount++ - var next string - if len(domains)/perPage > cursor { - next = strconv.Itoa(cursor + 1) - } + query := req.URL.Query() + cursor, _ := strconv.Atoi(query.Get("cursor")) + perPage, _ := strconv.Atoi(query.Get("per_page")) - start := cursor * perPage - if len(domains) < start { - start = cursor * len(domains) - } + var next string + if len(domains)/perPage > cursor { + next = strconv.Itoa(cursor + 1) + } - end := min(len(domains), (cursor+1)*perPage) + start := cursor * perPage + if len(domains) < start { + start = cursor * len(domains) + } - db := domainsBase{ - Domains: domains[start:end], - Meta: &govultr.Meta{ - Total: len(domains), - Links: &govultr.Links{Next: next}, - }, - } + end := min(len(domains), (cursor+1)*perPage) - err = json.NewEncoder(rw).Encode(db) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) + db := domainsBase{ + Domains: domains[start:end], + Meta: &govultr.Meta{ + Total: len(domains), + Links: &govultr.Links{Next: next}, + }, + } - zone, err := p.getHostedZone(t.Context(), test.domain) + err := json.NewEncoder(rw).Encode(db) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + })). + Build(t) + + zone, err := provider.getHostedZone(t.Context(), test.domain) require.NoError(t, err) assert.Equal(t, test.expected, zone) diff --git a/providers/dns/webnames/internal/client_test.go b/providers/dns/webnames/internal/client_test.go index ae14829a6..9507b6f98 100644 --- a/providers/dns/webnames/internal/client_test.go +++ b/providers/dns/webnames/internal/client_test.go @@ -1,74 +1,25 @@ package internal import ( - "fmt" - "io" - "net/http" "net/http/httptest" - "net/url" - "os" - "path" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, filename string, expectedParams url.Values) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("secret") + client.baseURL = server.URL + client.HTTPClient = server.Client() - mux := http.NewServeMux() - - mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } - - if req.Header.Get("Content-Type") != "application/x-www-form-urlencoded" { - http.Error(rw, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) - return - } - - err := req.ParseForm() - if err != nil { - http.Error(rw, err.Error(), http.StatusBadRequest) - return - } - - for k, v := range expectedParams { - val := req.PostForm.Get(k) - if len(v) == 0 { - http.Error(rw, fmt.Sprintf("%s: no value", k), http.StatusBadRequest) - return - } - - if val != v[0] { - http.Error(rw, fmt.Sprintf("%s: invalid value: %s != %s", k, val, v[0]), http.StatusBadRequest) - return - } - } - - file, err := os.Open(path.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = file.Close() }() - - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - server := httptest.NewServer(mux) - - client := NewClient("secret") - client.baseURL = server.URL - client.HTTPClient = server.Client() - - return client + return client, nil + }, + servermock.CheckHeader(). + WithContentTypeFromURLEncoded(), + ) } func TestClient_AddTXTRecord(t *testing.T) { @@ -93,13 +44,17 @@ func TestClient_AddTXTRecord(t *testing.T) { t.Run(test.desc, func(t *testing.T) { t.Parallel() - data := url.Values{} - data.Set("domain", "example.com") - data.Set("type", "TXT") - data.Set("record", "foo:txtTXTtxt") - data.Set("action", "add") - - client := setupTest(t, test.filename, data) + client := mockBuilder(). + Route("POST /", + servermock.ResponseFromFixture(test.filename), + servermock.CheckForm().Strict(). + With("domain", "example.com"). + With("type", "TXT"). + With("record", "foo:txtTXTtxt"). + With("action", "add"). + With("apikey", "secret"), + ). + Build(t) domain := "example.com" subDomain := "foo" @@ -133,13 +88,17 @@ func TestClient_RemoveTxtRecord(t *testing.T) { t.Run(test.desc, func(t *testing.T) { t.Parallel() - data := url.Values{} - data.Set("domain", "example.com") - data.Set("type", "TXT") - data.Set("record", "foo:txtTXTtxt") - data.Set("action", "delete") - - client := setupTest(t, test.filename, data) + client := mockBuilder(). + Route("POST /", + servermock.ResponseFromFixture(test.filename), + servermock.CheckForm().Strict(). + With("domain", "example.com"). + With("type", "TXT"). + With("record", "foo:txtTXTtxt"). + With("action", "delete"). + With("apikey", "secret"), + ). + Build(t) domain := "example.com" subDomain := "foo" diff --git a/providers/dns/wedos/internal/client_test.go b/providers/dns/wedos/internal/client_test.go index 4e011816b..f2515618a 100644 --- a/providers/dns/wedos/internal/client_test.go +++ b/providers/dns/wedos/internal/client_test.go @@ -4,58 +4,33 @@ import ( "fmt" "net/http" "net/http/httptest" - "os" "regexp" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupNew(t *testing.T, expectedForm, filename string) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("user", "secret") + client.baseURL = server.URL + client.HTTPClient = server.Client() - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) { - err := req.ParseForm() - if err != nil { - http.Error(rw, err.Error(), http.StatusBadRequest) - return - } - - exp := regexp.MustCompile(`"auth":"\w+",`) - - form := req.PostForm.Get("request") - form = exp.ReplaceAllString(form, `"auth":"xxx",`) - - if form != expectedForm { - t.Logf("invalid form data: %s", req.PostForm.Get("request")) - http.Error(rw, fmt.Sprintf("invalid form data: %s", req.PostForm.Get("request")), http.StatusBadRequest) - return - } - - data, err := os.ReadFile(fmt.Sprintf("./fixtures/%s.json", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - rw.Header().Set("Content-Type", "application/json") - _, _ = rw.Write(data) - }) - - client := NewClient("user", "secret") - client.baseURL = server.URL - - return client + return client, nil + }, + servermock.CheckHeader(). + WithContentTypeFromURLEncoded()) } func TestClient_GetRecords(t *testing.T) { - expectedForm := `{"request":{"user":"user","auth":"xxx","command":"dns-rows-list","data":{"domain":"example.com"}}}` - client := setupNew(t, expectedForm, commandDNSRowsList) + client := mockBuilder(). + Route("POST /", + servermock.ResponseFromFixture(commandDNSRowsList+".json"), + checkFormRequest(`{"request":{"user":"user","auth":"xxx","command":"dns-rows-list","data":{"domain":"example.com"}}}`)). + Build(t) records, err := client.GetRecords(t.Context(), "example.com.") require.NoError(t, err) @@ -94,9 +69,11 @@ func TestClient_GetRecords(t *testing.T) { } func TestClient_AddRecord(t *testing.T) { - expectedForm := `{"request":{"user":"user","auth":"xxx","command":"dns-row-add","data":{"domain":"example.com","name":"foo","ttl":1800,"type":"TXT","rdata":"foobar"}}}` - - client := setupNew(t, expectedForm, commandDNSRowAdd) + client := mockBuilder(). + Route("POST /", + servermock.ResponseFromFixture(commandDNSRowAdd+".json"), + checkFormRequest(`{"request":{"user":"user","auth":"xxx","command":"dns-row-add","data":{"domain":"example.com","name":"foo","ttl":1800,"type":"TXT","rdata":"foobar"}}}`)). + Build(t) record := DNSRow{ ID: "", @@ -111,9 +88,11 @@ func TestClient_AddRecord(t *testing.T) { } func TestClient_AddRecord_update(t *testing.T) { - expectedForm := `{"request":{"user":"user","auth":"xxx","command":"dns-row-update","data":{"row_id":"1","domain":"example.com","ttl":1800,"type":"TXT","rdata":"foobar"}}}` - - client := setupNew(t, expectedForm, commandDNSRowUpdate) + client := mockBuilder(). + Route("POST /", + servermock.ResponseFromFixture(commandDNSRowUpdate+".json"), + checkFormRequest(`{"request":{"user":"user","auth":"xxx","command":"dns-row-update","data":{"row_id":"1","domain":"example.com","ttl":1800,"type":"TXT","rdata":"foobar"}}}`)). + Build(t) record := DNSRow{ ID: "1", @@ -128,19 +107,45 @@ func TestClient_AddRecord_update(t *testing.T) { } func TestClient_DeleteRecord(t *testing.T) { - expectedForm := `{"request":{"user":"user","auth":"xxx","command":"dns-row-delete","data":{"row_id":"1","domain":"example.com","rdata":""}}}` - - client := setupNew(t, expectedForm, commandDNSRowDelete) + client := mockBuilder(). + Route("POST /", + servermock.ResponseFromFixture(commandDNSRowDelete+".json"), + checkFormRequest(`{"request":{"user":"user","auth":"xxx","command":"dns-row-delete","data":{"row_id":"1","domain":"example.com","rdata":""}}}`)). + Build(t) err := client.DeleteRecord(t.Context(), "example.com.", "1") require.NoError(t, err) } func TestClient_Commit(t *testing.T) { - expectedForm := `{"request":{"user":"user","auth":"xxx","command":"dns-domain-commit","data":{"name":"example.com"}}}` - - client := setupNew(t, expectedForm, commandDNSDomainCommit) + client := mockBuilder(). + Route("POST /", + servermock.ResponseFromFixture(commandDNSDomainCommit+".json"), + checkFormRequest(`{"request":{"user":"user","auth":"xxx","command":"dns-domain-commit","data":{"name":"example.com"}}}`)). + Build(t) err := client.Commit(t.Context(), "example.com.") require.NoError(t, err) } + +func checkFormRequest(data string) servermock.LinkFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + err := req.ParseForm() + if err != nil { + http.Error(rw, err.Error(), http.StatusBadRequest) + return + } + + form := regexp.MustCompile(`"auth":"\w+",`). + ReplaceAllString(req.PostForm.Get("request"), `"auth":"xxx",`) + + if form != data { + http.Error(rw, fmt.Sprintf("invalid form data: %s", req.PostForm.Get("request")), http.StatusBadRequest) + return + } + + next.ServeHTTP(rw, req) + }) + } +} diff --git a/providers/dns/westcn/internal/client_test.go b/providers/dns/westcn/internal/client_test.go index 6e21d7f61..f7bdac5c0 100644 --- a/providers/dns/westcn/internal/client_test.go +++ b/providers/dns/westcn/internal/client_test.go @@ -1,123 +1,53 @@ package internal import ( - "fmt" - "io" - "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" "time" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/text/encoding/simplifiedchinese" ) -type formExpectation func(values url.Values) error - -func setupTest(t *testing.T, filename string, expectations ...formExpectation) *Client { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc("POST /", func(rw http.ResponseWriter, req *http.Request) { - err := req.ParseForm() - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - commons := []formExpectation{ - expectValue("username", "user"), - expectNotEmpty("time"), - expectNotEmpty("token"), - } - - for _, common := range commons { - err = common(req.Form) +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient("user", "secret") if err != nil { - http.Error(rw, err.Error(), http.StatusBadRequest) - return + return nil, err } - } - for _, expectation := range expectations { - err = expectation(req.Form) - if err != nil { - http.Error(rw, err.Error(), http.StatusBadRequest) - return - } - } + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) - rw.Header().Set("Content-Type", "application/json; Charset=gb2312") - - file, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = file.Close() }() - - rw.WriteHeader(http.StatusOK) - _, err = io.Copy(rw, file) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - client, err := NewClient("user", "secret") - require.NoError(t, err) - - client.HTTPClient = server.Client() - client.baseURL, _ = url.Parse(server.URL) - - return client -} - -func expectValue(key, value string) formExpectation { - return func(values url.Values) error { - if values.Get(key) != value { - return fmt.Errorf("expected %s, got %s", value, values.Get(key)) - } - - return nil - } -} - -func expectNotEmpty(key string) formExpectation { - return func(values url.Values) error { - if values.Get(key) == "" { - return fmt.Errorf("%s missing", key) - } - - return nil - } -} - -func noop() formExpectation { - return func(_ url.Values) error { - return nil - } + return client, nil + }, + servermock.CheckHeader(). + WithContentTypeFromURLEncoded()) } func TestClientAddRecord(t *testing.T) { - expectValue("act", "adddnsrecord") - - client := setupTest(t, "adddnsrecord.json", - expectValue("act", "adddnsrecord"), - expectValue("domain", "example.com"), - expectValue("host", "@"), - expectValue("type", "TXT"), - expectValue("value", "txtTXTtxt"), - expectValue("ttl", "60"), - ) + client := mockBuilder(). + Route("POST /domain/", + servermock.ResponseFromFixture("adddnsrecord.json"). + WithHeader("Content-Type", "application/json", "Charset=gb2312"), + servermock.CheckQueryParameter().Strict(). + With("act", "adddnsrecord"), + servermock.CheckForm().UsePostForm().Strict(). + With("domain", "example.com"). + With("host", "@"). + With("ttl", "60"). + With("type", "TXT"). + With("value", "txtTXTtxt"). + // With("act", "adddnsrecord"). + With("username", "user"). + WithRegexp("time", `\d+`). + WithRegexp("token", `[a-z0-9]{32}`), + ). + Build(t) record := Record{ Domain: "example.com", @@ -134,7 +64,13 @@ func TestClientAddRecord(t *testing.T) { } func TestClientAddRecord_error(t *testing.T) { - client := setupTest(t, "error.json", noop()) + client := mockBuilder(). + Route("POST /domain/", + servermock.ResponseFromFixture("error.json"). + WithHeader("Content-Type", "application/json", "Charset=gb2312"), + servermock.CheckQueryParameter().Strict(). + With("act", "adddnsrecord")). + Build(t) record := Record{ Domain: "example.com", @@ -151,18 +87,34 @@ func TestClientAddRecord_error(t *testing.T) { } func TestClientDeleteRecord(t *testing.T) { - client := setupTest(t, "deldnsrecord.json", - expectValue("act", "deldnsrecord"), - expectValue("domain", "example.com"), - ) + client := mockBuilder(). + Route("POST /domain/", + servermock.ResponseFromFixture("deldnsrecord.json"). + WithHeader("Content-Type", "application/json", "Charset=gb2312"), + servermock.CheckQueryParameter().Strict(). + With("act", "deldnsrecord"), + servermock.CheckForm().UsePostForm().Strict(). + With("id", "123"). + With("domain", "example.com"). + With("username", "user"). + WithRegexp("time", `\d+`). + WithRegexp("token", `[a-z0-9]{32}`), + ). + Build(t) err := client.DeleteRecord(t.Context(), "example.com", 123) require.NoError(t, err) } func TestClientDeleteRecord_error(t *testing.T) { - client := setupTest(t, "error.json", noop()) - + client := mockBuilder(). + Route("POST /domain/", + servermock.ResponseFromFixture("error.json"). + WithHeader("Content-Type", "application/json", "Charset=gb2312"), + servermock.CheckQueryParameter().Strict(). + With("act", "deldnsrecord"), + ). + Build(t) err := client.DeleteRecord(t.Context(), "example.com", 123) require.Error(t, err) diff --git a/providers/dns/yandex/internal/client_test.go b/providers/dns/yandex/internal/client_test.go index 55de81bc7..4bb3357a6 100644 --- a/providers/dns/yandex/internal/client_test.go +++ b/providers/dns/yandex/internal/client_test.go @@ -1,327 +1,133 @@ package internal import ( - "encoding/json" - "net/http" "net/http/httptest" "net/url" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T) (*Client, *http.ServeMux) { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - +func setupClient(server *httptest.Server) (*Client, error) { client, err := NewClient("lego") - require.NoError(t, err) + if err != nil { + return nil, err + } client.HTTPClient = server.Client() client.baseURL, _ = url.Parse(server.URL) - return client, mux + return client, nil } func TestAddRecord(t *testing.T) { - testCases := []struct { - desc string - handler http.HandlerFunc - data Record - expectError bool - }{ - { - desc: "success", - handler: func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, http.MethodPost, r.Method) - assert.Equal(t, "lego", r.Header.Get(pddTokenHeader)) + client := servermock.NewBuilder[*Client](setupClient). + Route("POST /add", + servermock.ResponseFromFixture("add_record.json"), + servermock.CheckHeader(). + WithContentTypeFromURLEncoded(), + servermock.CheckForm().Strict(). + With("domain", "example.com"). + With("subdomain", "foo"). + With("ttl", "300"). + With("content", "txtTXTtxtTXTtxtTXT"). + With("type", "TXT")). + Build(t) - err := r.ParseForm() - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - assert.Equal(t, `content=txtTXTtxtTXTtxtTXT&domain=example.com&subdomain=foo&ttl=300&type=TXT`, r.PostForm.Encode()) - - response := AddResponse{ - Domain: "example.com", - Record: &Record{ - ID: 1, - Type: "TXT", - Domain: "example.com", - SubDomain: "foo", - FQDN: "foo.example.com.", - Content: "txtTXTtxtTXTtxtTXT", - TTL: 300, - }, - BaseResponse: BaseResponse{ - Success: "ok", - }, - } - - err = json.NewEncoder(w).Encode(response) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }, - data: Record{ - Domain: "example.com", - Type: "TXT", - Content: "txtTXTtxtTXTtxtTXT", - SubDomain: "foo", - TTL: 300, - }, - }, - { - desc: "error", - handler: func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, http.MethodPost, r.Method) - assert.Equal(t, "lego", r.Header.Get(pddTokenHeader)) - - err := r.ParseForm() - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - assert.Equal(t, `content=txtTXTtxtTXTtxtTXT&domain=example.com&subdomain=foo&ttl=300&type=TXT`, r.PostForm.Encode()) - - response := AddResponse{ - Domain: "example.com", - BaseResponse: BaseResponse{ - Success: "error", - Error: "bad things", - }, - } - - err = json.NewEncoder(w).Encode(response) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }, - data: Record{ - Domain: "example.com", - Type: "TXT", - Content: "txtTXTtxtTXTtxtTXT", - SubDomain: "foo", - TTL: 300, - }, - expectError: true, - }, + data := Record{ + Domain: "example.com", + Type: "TXT", + Content: "txtTXTtxtTXTtxtTXT", + SubDomain: "foo", + TTL: 300, } - for _, test := range testCases { - t.Run(test.desc, func(t *testing.T) { - client, mux := setupTest(t) + record, err := client.AddRecord(t.Context(), data) + require.NoError(t, err) + require.NotNil(t, record) +} - mux.HandleFunc("/add", test.handler) +func TestAddRecord_error(t *testing.T) { + client := servermock.NewBuilder[*Client](setupClient). + Route("POST /add", + servermock.ResponseFromFixture("add_record_error.json"), + servermock.CheckHeader(). + WithContentTypeFromURLEncoded()). + Build(t) - record, err := client.AddRecord(t.Context(), test.data) - if test.expectError { - require.Error(t, err) - require.Nil(t, record) - } else { - require.NoError(t, err) - require.NotNil(t, record) - } - }) + data := Record{ + Domain: "example.com", + Type: "TXT", + Content: "txtTXTtxtTXTtxtTXT", + SubDomain: "foo", + TTL: 300, } + + _, err := client.AddRecord(t.Context(), data) + require.EqualError(t, err, "error during operation: error bad things") } func TestRemoveRecord(t *testing.T) { - testCases := []struct { - desc string - handler http.HandlerFunc - data Record - expectError bool - }{ - { - desc: "success", - handler: func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, http.MethodPost, r.Method) - assert.Equal(t, "lego", r.Header.Get(pddTokenHeader)) + client := servermock.NewBuilder[*Client](setupClient). + Route("POST /del", + servermock.ResponseFromFixture("remove_record.json"), + servermock.CheckHeader(). + WithContentTypeFromURLEncoded(), + servermock.CheckForm().Strict(). + With("domain", "example.com"). + With("record_id", "6")). + Build(t) - err := r.ParseForm() - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - assert.Equal(t, `domain=example.com&record_id=6`, r.PostForm.Encode()) - - response := RemoveResponse{ - Domain: "example.com", - RecordID: 6, - BaseResponse: BaseResponse{ - Success: "ok", - }, - } - - err = json.NewEncoder(w).Encode(response) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }, - data: Record{ - ID: 6, - Domain: "example.com", - }, - }, - { - desc: "error", - handler: func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, http.MethodPost, r.Method) - assert.Equal(t, "lego", r.Header.Get(pddTokenHeader)) - - err := r.ParseForm() - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - assert.Equal(t, `domain=example.com&record_id=6`, r.PostForm.Encode()) - - response := RemoveResponse{ - Domain: "example.com", - RecordID: 6, - BaseResponse: BaseResponse{ - Success: "error", - Error: "bad things", - }, - } - - err = json.NewEncoder(w).Encode(response) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }, - data: Record{ - ID: 6, - Domain: "example.com", - }, - expectError: true, - }, + data := Record{ + ID: 6, + Domain: "example.com", } - for _, test := range testCases { - t.Run(test.desc, func(t *testing.T) { - client, mux := setupTest(t) + id, err := client.RemoveRecord(t.Context(), data) + require.NoError(t, err) - mux.HandleFunc("/del", test.handler) + assert.Equal(t, 6, id) +} - id, err := client.RemoveRecord(t.Context(), test.data) - if test.expectError { - require.Error(t, err) - require.Equal(t, 0, id) - } else { - require.NoError(t, err) - require.Equal(t, 6, id) - } - }) +func TestRemoveRecord_error(t *testing.T) { + client := servermock.NewBuilder[*Client](setupClient). + Route("POST /del", + servermock.ResponseFromFixture("remove_record_error.json"), + servermock.CheckHeader(). + WithContentTypeFromURLEncoded()). + Build(t) + + data := Record{ + ID: 6, + Domain: "example.com", } + + _, err := client.RemoveRecord(t.Context(), data) + require.EqualError(t, err, "error during operation: error bad things") } func TestGetRecords(t *testing.T) { - testCases := []struct { - desc string - handler http.HandlerFunc - domain string - expectError bool - }{ - { - desc: "success", - handler: func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, http.MethodGet, r.Method) - assert.Equal(t, "lego", r.Header.Get(pddTokenHeader)) + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /list", + servermock.ResponseFromFixture("get_records.json"), + servermock.CheckForm().Strict(). + With("domain", "example.com")). + Build(t) - assert.Equal(t, "domain=example.com", r.URL.RawQuery) + records, err := client.GetRecords(t.Context(), "example.com") + require.NoError(t, err) - response := ListResponse{ - Domain: "example.com", - Records: []Record{ - { - ID: 1, - Type: "TXT", - Domain: "example.com", - SubDomain: "foo", - FQDN: "foo.example.com.", - Content: "txtTXTtxtTXTtxtTXT", - TTL: 300, - }, - { - ID: 2, - Type: "NS", - Domain: "example.com", - SubDomain: "foo", - FQDN: "foo.example.com.", - Content: "bar", - TTL: 300, - }, - }, - BaseResponse: BaseResponse{ - Success: "ok", - }, - } - - err := json.NewEncoder(w).Encode(response) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }, - domain: "example.com", - }, - { - desc: "error", - handler: func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, http.MethodGet, r.Method) - assert.Equal(t, "lego", r.Header.Get(pddTokenHeader)) - - assert.Equal(t, "domain=example.com", r.URL.RawQuery) - - response := ListResponse{ - Domain: "example.com", - BaseResponse: BaseResponse{ - Success: "error", - Error: "bad things", - }, - } - - err := json.NewEncoder(w).Encode(response) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }, - domain: "example.com", - expectError: true, - }, - } - - for _, test := range testCases { - t.Run(test.desc, func(t *testing.T) { - t.Parallel() - client, mux := setupTest(t) - - mux.HandleFunc("/list", test.handler) - - records, err := client.GetRecords(t.Context(), test.domain) - if test.expectError { - require.Error(t, err) - require.Empty(t, records) - } else { - require.NoError(t, err) - require.Len(t, records, 2) - } - }) - } + require.Len(t, records, 2) +} + +func TestGetRecords_error(t *testing.T) { + client := servermock.NewBuilder[*Client](setupClient). + Route("GET /list", + servermock.ResponseFromFixture("get_records_error.json")). + Build(t) + + _, err := client.GetRecords(t.Context(), "example.com") + require.EqualError(t, err, "error during operation: error bad things") } diff --git a/providers/dns/yandex/internal/fixtures/add_record.json b/providers/dns/yandex/internal/fixtures/add_record.json new file mode 100644 index 000000000..1e4452d1d --- /dev/null +++ b/providers/dns/yandex/internal/fixtures/add_record.json @@ -0,0 +1,13 @@ +{ + "success": "ok", + "domain": "example.com", + "record": { + "record_id": 1, + "domain": "example.com", + "subdomain": "foo", + "fqdn": "foo.example.com.", + "ttl": 300, + "type": "TXT", + "content": "txtTXTtxtTXTtxtTXT" + } +} diff --git a/providers/dns/yandex/internal/fixtures/add_record_error.json b/providers/dns/yandex/internal/fixtures/add_record_error.json new file mode 100644 index 000000000..932ccd674 --- /dev/null +++ b/providers/dns/yandex/internal/fixtures/add_record_error.json @@ -0,0 +1,5 @@ +{ + "success": "error", + "error": "bad things", + "domain": "example.com" +} diff --git a/providers/dns/yandex/internal/fixtures/get_records.json b/providers/dns/yandex/internal/fixtures/get_records.json new file mode 100644 index 000000000..e538834b4 --- /dev/null +++ b/providers/dns/yandex/internal/fixtures/get_records.json @@ -0,0 +1,24 @@ +{ + "success": "ok", + "domain": "example.com", + "records": [ + { + "record_id": 1, + "domain": "example.com", + "subdomain": "foo", + "fqdn": "foo.example.com.", + "ttl": 300, + "type": "TXT", + "content": "txtTXTtxtTXTtxtTXT" + }, + { + "record_id": 2, + "domain": "example.com", + "subdomain": "foo", + "fqdn": "foo.example.com.", + "ttl": 300, + "type": "NS", + "content": "bar" + } + ] +} diff --git a/providers/dns/yandex/internal/fixtures/get_records_error.json b/providers/dns/yandex/internal/fixtures/get_records_error.json new file mode 100644 index 000000000..932ccd674 --- /dev/null +++ b/providers/dns/yandex/internal/fixtures/get_records_error.json @@ -0,0 +1,5 @@ +{ + "success": "error", + "error": "bad things", + "domain": "example.com" +} diff --git a/providers/dns/yandex/internal/fixtures/remove_record.json b/providers/dns/yandex/internal/fixtures/remove_record.json new file mode 100644 index 000000000..3241ba9dc --- /dev/null +++ b/providers/dns/yandex/internal/fixtures/remove_record.json @@ -0,0 +1,5 @@ +{ + "success": "ok", + "domain": "example.com", + "record_id": 6 +} diff --git a/providers/dns/yandex/internal/fixtures/remove_record_error.json b/providers/dns/yandex/internal/fixtures/remove_record_error.json new file mode 100644 index 000000000..cd1471c9d --- /dev/null +++ b/providers/dns/yandex/internal/fixtures/remove_record_error.json @@ -0,0 +1,6 @@ +{ + "success": "error", + "error": "bad things", + "domain": "example.com", + "record_id": 6 +} diff --git a/providers/dns/yandex360/internal/client_test.go b/providers/dns/yandex360/internal/client_test.go index 83f66800f..aa21672e4 100644 --- a/providers/dns/yandex360/internal/client_test.go +++ b/providers/dns/yandex360/internal/client_test.go @@ -1,59 +1,39 @@ package internal import ( - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, pattern, method string, status int, filename string) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient("secret", 123456) + if err != nil { + return nil, err + } - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) - mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - open, err := os.Open(filepath.Join("fixtures", filename)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = open.Close() }() - - rw.WriteHeader(status) - _, err = io.Copy(rw, open) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - client, err := NewClient("secret", 123456) - require.NoError(t, err) - - client.HTTPClient = server.Client() - client.baseURL, _ = url.Parse(server.URL) - - return client + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithAuthorization("OAuth secret")) } func TestClient_AddRecord(t *testing.T) { - client := setupTest(t, "/directory/v1/org/123456/domains/example.com/dns", http.MethodPost, http.StatusOK, "add-record.json") + client := mockBuilder(). + Route("POST /directory/v1/org/123456/domains/example.com/dns", + servermock.ResponseFromFixture("add-record.json"), + servermock.CheckRequestJSONBody(`{"name":"_acme-challenge","text":"txtxtxt","ttl":60,"type":"TXT"}`)). + Build(t) record := Record{ Name: "_acme-challenge", @@ -77,7 +57,11 @@ func TestClient_AddRecord(t *testing.T) { } func TestClient_AddRecord_error(t *testing.T) { - client := setupTest(t, "/directory/v1/org/123456/domains/example.com/dns", http.MethodGet, http.StatusUnauthorized, "error.json") + client := mockBuilder(). + Route("POST /directory/v1/org/123456/domains/example.com/dns", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) record := Record{ Name: "_acme-challenge", @@ -93,14 +77,21 @@ func TestClient_AddRecord_error(t *testing.T) { } func TestClient_DeleteRecord(t *testing.T) { - client := setupTest(t, "/directory/v1/org/123456/domains/example.com/dns/789456", http.MethodDelete, http.StatusOK, "delete-record.json") + client := mockBuilder(). + Route("DELETE /directory/v1/org/123456/domains/example.com/dns/789456", + servermock.ResponseFromFixture("delete-record.json")). + Build(t) err := client.DeleteRecord(t.Context(), "example.com", 789456) require.NoError(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { - client := setupTest(t, "/directory/v1/org/123456/domains/example.com/dns/789456", http.MethodDelete, http.StatusUnauthorized, "error.json") + client := mockBuilder(). + Route("DELETE /directory/v1/org/123456/domains/example.com/dns/789456", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized)). + Build(t) err := client.DeleteRecord(t.Context(), "example.com", 789456) require.Error(t, err) diff --git a/providers/dns/yandexcloud/yandexcloud.go b/providers/dns/yandexcloud/yandexcloud.go index 22da14404..ca44ab82b 100644 --- a/providers/dns/yandexcloud/yandexcloud.go +++ b/providers/dns/yandexcloud/yandexcloud.go @@ -103,7 +103,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { } // Present creates a TXT record to fulfill the dns-01 challenge. -func (r *DNSProvider) Present(domain, _, keyAuth string) error { +func (d *DNSProvider) Present(domain, _, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) @@ -113,7 +113,7 @@ func (r *DNSProvider) Present(domain, _, keyAuth string) error { ctx := context.Background() - zones, err := r.getZones(ctx) + zones, err := d.getZones(ctx) if err != nil { return fmt.Errorf("yandexcloud: %w", err) } @@ -135,7 +135,7 @@ func (r *DNSProvider) Present(domain, _, keyAuth string) error { return fmt.Errorf("yandexcloud: %w", err) } - err = r.upsertRecordSetData(ctx, zoneID, subDomain, info.Value) + err = d.upsertRecordSetData(ctx, zoneID, subDomain, info.Value) if err != nil { return fmt.Errorf("yandexcloud: %w", err) } @@ -144,7 +144,7 @@ func (r *DNSProvider) Present(domain, _, keyAuth string) error { } // CleanUp removes the TXT record matching the specified parameters. -func (r *DNSProvider) CleanUp(domain, _, keyAuth string) error { +func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) @@ -154,7 +154,7 @@ func (r *DNSProvider) CleanUp(domain, _, keyAuth string) error { ctx := context.Background() - zones, err := r.getZones(ctx) + zones, err := d.getZones(ctx) if err != nil { return fmt.Errorf("yandexcloud: %w", err) } @@ -176,7 +176,7 @@ func (r *DNSProvider) CleanUp(domain, _, keyAuth string) error { return fmt.Errorf("yandexcloud: %w", err) } - err = r.removeRecordSetData(ctx, zoneID, subDomain, info.Value) + err = d.removeRecordSetData(ctx, zoneID, subDomain, info.Value) if err != nil { return fmt.Errorf("yandexcloud: %w", err) } @@ -186,17 +186,17 @@ func (r *DNSProvider) CleanUp(domain, _, keyAuth string) error { // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. -func (r *DNSProvider) Timeout() (timeout, interval time.Duration) { - return r.config.PropagationTimeout, r.config.PollingInterval +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval } // getZones retrieves available zones from yandex cloud. -func (r *DNSProvider) getZones(ctx context.Context) ([]*ycdns.DnsZone, error) { +func (d *DNSProvider) getZones(ctx context.Context) ([]*ycdns.DnsZone, error) { list := &ycdns.ListDnsZonesRequest{ - FolderId: r.config.FolderID, + FolderId: d.config.FolderID, } - response, err := r.client.DNS().DnsZone().List(ctx, list) + response, err := d.client.DNS().DnsZone().List(ctx, list) if err != nil { return nil, errors.New("unable to fetch dns zones") } @@ -204,14 +204,14 @@ func (r *DNSProvider) getZones(ctx context.Context) ([]*ycdns.DnsZone, error) { return response.GetDnsZones(), nil } -func (r *DNSProvider) upsertRecordSetData(ctx context.Context, zoneID, name, value string) error { +func (d *DNSProvider) upsertRecordSetData(ctx context.Context, zoneID, name, value string) error { get := &ycdns.GetDnsZoneRecordSetRequest{ DnsZoneId: zoneID, Name: name, Type: "TXT", } - exist, err := r.client.DNS().DnsZone().GetRecordSet(ctx, get) + exist, err := d.client.DNS().DnsZone().GetRecordSet(ctx, get) if err != nil { if !strings.Contains(err.Error(), "RecordSet not found") { return err @@ -221,7 +221,7 @@ func (r *DNSProvider) upsertRecordSetData(ctx context.Context, zoneID, name, val record := &ycdns.RecordSet{ Name: name, Type: "TXT", - Ttl: int64(r.config.TTL), + Ttl: int64(d.config.TTL), Data: []string{}, } @@ -243,19 +243,19 @@ func (r *DNSProvider) upsertRecordSetData(ctx context.Context, zoneID, name, val Additions: []*ycdns.RecordSet{record}, } - _, err = r.client.DNS().DnsZone().UpdateRecordSets(ctx, update) + _, err = d.client.DNS().DnsZone().UpdateRecordSets(ctx, update) return err } -func (r *DNSProvider) removeRecordSetData(ctx context.Context, zoneID, name, value string) error { +func (d *DNSProvider) removeRecordSetData(ctx context.Context, zoneID, name, value string) error { get := &ycdns.GetDnsZoneRecordSetRequest{ DnsZoneId: zoneID, Name: name, Type: "TXT", } - previousRecord, err := r.client.DNS().DnsZone().GetRecordSet(ctx, get) + previousRecord, err := d.client.DNS().DnsZone().GetRecordSet(ctx, get) if err != nil { if strings.Contains(err.Error(), "RecordSet not found") { // RecordSet is not present, nothing to do @@ -272,7 +272,7 @@ func (r *DNSProvider) removeRecordSetData(ctx context.Context, zoneID, name, val record := &ycdns.RecordSet{ Name: name, Type: "TXT", - Ttl: int64(r.config.TTL), + Ttl: int64(d.config.TTL), Data: []string{}, } @@ -291,7 +291,7 @@ func (r *DNSProvider) removeRecordSetData(ctx context.Context, zoneID, name, val Additions: additions, } - _, err = r.client.DNS().DnsZone().UpdateRecordSets(ctx, update) + _, err = d.client.DNS().DnsZone().UpdateRecordSets(ctx, update) return err } diff --git a/providers/dns/zoneee/internal/client_test.go b/providers/dns/zoneee/internal/client_test.go index 04676877f..c2f0e781e 100644 --- a/providers/dns/zoneee/internal/client_test.go +++ b/providers/dns/zoneee/internal/client_test.go @@ -1,62 +1,34 @@ package internal import ( - "fmt" - "io" "net/http" "net/http/httptest" "net/url" - "os" - "path/filepath" "testing" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupTest(t *testing.T, method, pattern string, status int, file string) *Client { - t.Helper() +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client := NewClient("user", "secret") + client.HTTPClient = server.Client() + client.BaseURL, _ = url.Parse(server.URL) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) { - if req.Method != method { - http.Error(rw, fmt.Sprintf("unsupported method %s", req.Method), http.StatusBadRequest) - return - } - - if file == "" { - rw.WriteHeader(status) - return - } - - open, err := os.Open(filepath.Join("fixtures", file)) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - defer func() { _ = open.Close() }() - - rw.WriteHeader(status) - _, err = io.Copy(rw, open) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - }) - - client := NewClient("user", "secret") - client.HTTPClient = server.Client() - client.BaseURL, _ = url.Parse(server.URL) - - return client + return client, nil + }, + servermock.CheckHeader().WithJSONHeaders(). + WithBasicAuth("user", "secret"), + ) } func TestClient_GetTxtRecords(t *testing.T) { - client := setupTest(t, http.MethodGet, "/dns/example.com/txt", http.StatusOK, "get-txt-records.json") + client := mockBuilder(). + Route("GET /dns/example.com/txt", servermock.ResponseFromFixture("get-txt-records.json")). + Build(t) records, err := client.GetTxtRecords(t.Context(), "example.com") require.NoError(t, err) @@ -69,7 +41,12 @@ func TestClient_GetTxtRecords(t *testing.T) { } func TestClient_AddTxtRecord(t *testing.T) { - client := setupTest(t, http.MethodPost, "/dns/example.com/txt", http.StatusCreated, "create-txt-record.json") + client := mockBuilder(). + Route("POST /dns/example.com/txt", + servermock.ResponseFromFixture("create-txt-record.json"). + WithStatusCode(http.StatusCreated), + servermock.CheckRequestJSONBody(`{"name":"prefix.example.com","destination":"server.example.com"}`)). + Build(t) records, err := client.AddTxtRecord(t.Context(), "example.com", TXTRecord{Name: "prefix.example.com", Destination: "server.example.com"}) require.NoError(t, err) @@ -82,7 +59,11 @@ func TestClient_AddTxtRecord(t *testing.T) { } func TestClient_RemoveTxtRecord(t *testing.T) { - client := setupTest(t, http.MethodDelete, "/dns/example.com/txt/123", http.StatusNoContent, "") + client := mockBuilder(). + Route("DELETE /dns/example.com/txt/123", + servermock.Noop(). + WithStatusCode(http.StatusNoContent)). + Build(t) err := client.RemoveTxtRecord(t.Context(), "example.com", "123") require.NoError(t, err) diff --git a/providers/dns/zoneee/zoneee_test.go b/providers/dns/zoneee/zoneee_test.go index 1f2909fa7..6f50cf36e 100644 --- a/providers/dns/zoneee/zoneee_test.go +++ b/providers/dns/zoneee/zoneee_test.go @@ -6,17 +6,22 @@ import ( "net/http" "net/http/httptest" "net/url" - "path" "testing" "time" "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/go-acme/lego/v4/providers/dns/zoneee/internal" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" +const ( + fakeUsername = "user" + fakeAPIKey = "secret" +) + var envTest = tester.NewEnvTest(EnvEndpoint, EnvAPIUser, EnvAPIKey). WithLiveTestRequirements(EnvAPIUser, EnvAPIKey). WithDomain(envDomain) @@ -94,7 +99,6 @@ func TestNewDNSProviderConfig(t *testing.T) { desc string apiUser string apiKey string - endpoint string expected string }{ { @@ -124,10 +128,6 @@ func TestNewDNSProviderConfig(t *testing.T) { config.APIKey = test.apiKey config.Username = test.apiUser - if test.endpoint != "" { - config.Endpoint = mustParse(test.endpoint) - } - p, err := NewDNSProviderConfig(config) if test.expected == "" { @@ -147,57 +147,33 @@ func TestDNSProvider_Present(t *testing.T) { testCases := []struct { desc string - username string - apiKey string - handlers map[string]http.HandlerFunc + builder *servermock.Builder[*DNSProvider] expectedError string }{ { - desc: "success", - username: "bar", - apiKey: "foo", - handlers: map[string]http.HandlerFunc{ - path.Join("/", "dns", hostedZone, "txt"): mockHandlerCreateRecord, - }, + desc: "success", + builder: mockBuilder(fakeUsername, fakeAPIKey). + Route("POST /dns/"+hostedZone+"/txt", + mockHandlerCreateRecord()), }, { - desc: "invalid auth", - username: "nope", - apiKey: "foo", - handlers: map[string]http.HandlerFunc{ - path.Join("/", "dns", hostedZone, "txt"): mockHandlerCreateRecord, - }, + desc: "invalid auth", + builder: mockBuilder("nope", "nope"). + Route("POST /dns/"+hostedZone+"/txt", nil), expectedError: "zoneee: unexpected status code: [status code: 401] body: Unauthorized", }, { desc: "error", - username: "bar", - apiKey: "foo", + builder: mockBuilder(fakeUsername, fakeAPIKey), expectedError: "zoneee: unexpected status code: [status code: 404] body: 404 page not found", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - t.Parallel() + provider := test.builder.Build(t) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - for uri, handler := range test.handlers { - mux.HandleFunc(uri, handler) - } - - config := NewDefaultConfig() - config.Endpoint = mustParse(server.URL) - config.Username = test.username - config.APIKey = test.apiKey - - p, err := NewDNSProviderConfig(config) - require.NoError(t, err) - - err = p.Present(domain, "token", "key") + err := provider.Present(domain, "token", "key") if test.expectedError == "" { require.NoError(t, err) } else { @@ -213,81 +189,49 @@ func TestDNSProvider_Cleanup(t *testing.T) { testCases := []struct { desc string - username string - apiKey string - handlers map[string]http.HandlerFunc + builder *servermock.Builder[*DNSProvider] expectedError string }{ { - desc: "success", - username: "bar", - apiKey: "foo", - handlers: map[string]http.HandlerFunc{ - path.Join("/", "dns", hostedZone, "txt"): mockHandlerGetRecords([]internal.TXTRecord{{ - ID: "1234", - Name: domain, - Destination: "LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM", - Delete: true, - Modify: true, - }}), - path.Join("/", "dns", hostedZone, "txt", "1234"): mockHandlerDeleteRecord, - }, + desc: "success", + builder: mockBuilder(fakeUsername, fakeAPIKey). + Route("GET /dns/"+hostedZone+"/txt", + mockHandlerGetRecords([]internal.TXTRecord{{ + ID: "1234", + Name: domain, + Destination: "LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM", + Delete: true, + Modify: true, + }})). + Route("DELETE /dns/"+hostedZone+"/txt/1234", + servermock.Noop(). + WithStatusCode(http.StatusNoContent)), }, { - desc: "no txt records", - username: "bar", - apiKey: "foo", - handlers: map[string]http.HandlerFunc{ - path.Join("/", "dns", hostedZone, "txt"): mockHandlerGetRecords([]internal.TXTRecord{}), - path.Join("/", "dns", hostedZone, "txt", "1234"): mockHandlerDeleteRecord, - }, + desc: "no txt records", + builder: mockBuilder(fakeUsername, fakeAPIKey). + Route("GET /dns/"+hostedZone+"/txt", + mockHandlerGetRecords([]internal.TXTRecord{})), expectedError: "zoneee: txt record does not exist for LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM", }, { - desc: "invalid auth", - username: "nope", - apiKey: "foo", - handlers: map[string]http.HandlerFunc{ - path.Join("/", "dns", hostedZone, "txt"): mockHandlerGetRecords([]internal.TXTRecord{{ - ID: "1234", - Name: domain, - Destination: "LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM", - Delete: true, - Modify: true, - }}), - path.Join("/", "dns", hostedZone, "txt", "1234"): mockHandlerDeleteRecord, - }, + desc: "invalid auth", + builder: mockBuilder("nope", "nope"). + Route("GET /dns/"+hostedZone+"/txt", nil), expectedError: "zoneee: unexpected status code: [status code: 401] body: Unauthorized", }, { desc: "error", - username: "bar", - apiKey: "foo", + builder: mockBuilder(fakeUsername, fakeAPIKey), expectedError: "zoneee: unexpected status code: [status code: 404] body: 404 page not found", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - t.Parallel() + provider := test.builder.Build(t) - mux := http.NewServeMux() - server := httptest.NewServer(mux) - t.Cleanup(server.Close) - - for uri, handler := range test.handlers { - mux.HandleFunc(uri, handler) - } - - config := NewDefaultConfig() - config.Endpoint = mustParse(server.URL) - config.Username = test.username - config.APIKey = test.apiKey - - p, err := NewDNSProviderConfig(config) - require.NoError(t, err) - - err = p.CleanUp(domain, "token", "key") + err := provider.CleanUp(domain, "token", "key") if test.expectedError == "" { require.NoError(t, err) } else { @@ -325,72 +269,57 @@ func TestLiveCleanUp(t *testing.T) { require.NoError(t, err) } -func mustParse(rawURL string) *url.URL { - uri, err := url.Parse(rawURL) - if err != nil { - panic(err) - } - return uri +func mockBuilder(username, apiKey string) *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.Endpoint, _ = url.Parse(server.URL) + config.Username = username + config.APIKey = apiKey + + return NewDNSProviderConfig(config) + }, + checkBasicAuth()) } -func mockHandlerCreateRecord(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(rw, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } +func mockHandlerCreateRecord() http.HandlerFunc { + return encodeJSONHandler(func(req *http.Request, rw http.ResponseWriter) (any, error) { + record := internal.TXTRecord{} + err := json.NewDecoder(req.Body).Decode(&record) + if err != nil { + return nil, err + } - username, apiKey, ok := req.BasicAuth() - if username != "bar" || apiKey != "foo" || !ok { - rw.Header().Set("WWW-Authenticate", fmt.Sprintf(`Basic realm=%q`, "Please enter your username and API key.")) - http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return - } + record.ID = "1234" + record.Delete = true + record.Modify = true + record.ResourceURL = req.URL.String() + "/1234" - record := internal.TXTRecord{} - err := json.NewDecoder(req.Body).Decode(&record) - if err != nil { - http.Error(rw, err.Error(), http.StatusBadRequest) - return - } - - record.ID = "1234" - record.Delete = true - record.Modify = true - record.ResourceURL = req.URL.String() + "/1234" - - bytes, err := json.Marshal([]internal.TXTRecord{record}) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - if _, err = rw.Write(bytes); err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } + return []internal.TXTRecord{record}, nil + }) } func mockHandlerGetRecords(records []internal.TXTRecord) http.HandlerFunc { - return func(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(rw, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } - - username, apiKey, ok := req.BasicAuth() - if username != "bar" || apiKey != "foo" || !ok { - rw.Header().Set("WWW-Authenticate", fmt.Sprintf(`Basic realm=%q`, "Please enter your username and API key.")) - http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return - } - - for _, value := range records { - if value.ResourceURL == "" { - value.ResourceURL = req.URL.String() + "/" + value.ID + return encodeJSONHandler(func(req *http.Request, rw http.ResponseWriter) (any, error) { + for _, record := range records { + if record.ResourceURL == "" { + record.ResourceURL = req.URL.String() + "/" + record.ID } } - bytes, err := json.Marshal(records) + return records, nil + }) +} + +func encodeJSONHandler(build func(req *http.Request, rw http.ResponseWriter) (any, error)) http.HandlerFunc { + return func(rw http.ResponseWriter, req *http.Request) { + data, err := build(req, rw) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + bytes, err := json.Marshal(data) if err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return @@ -403,18 +332,17 @@ func mockHandlerGetRecords(records []internal.TXTRecord) http.HandlerFunc { } } -func mockHandlerDeleteRecord(rw http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodDelete { - http.Error(rw, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } +func checkBasicAuth() servermock.LinkFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + username, apiKey, ok := req.BasicAuth() + if username != fakeUsername || apiKey != fakeAPIKey || !ok { + rw.Header().Set("WWW-Authenticate", fmt.Sprintf(`Basic realm=%q`, "Please enter your username and API key.")) + http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + return + } - username, apiKey, ok := req.BasicAuth() - if username != "bar" || apiKey != "foo" || !ok { - rw.Header().Set("WWW-Authenticate", fmt.Sprintf(`Basic realm=%q`, "Please enter your username and API key.")) - http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return + next.ServeHTTP(rw, req) + }) } - - rw.WriteHeader(http.StatusNoContent) }