diff --git a/README.md b/README.md index f7c2ca2..4320b81 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ A standalone Terraform provider for Dynu DNS. Implemented: - Provider authentication using `api_key` or `DYNU_API_KEY` +- Optional provider `base_url` override for local test/dev setups - Data sources: - `dynu_domains` - `dynu_domain` @@ -28,7 +29,7 @@ The repository can be hosted elsewhere during development, but module and provid - Terraform `>= 1.5` - Go `>= 1.23` -- Dynu API key +- Dynu API key for live API usage ## Authentication @@ -62,8 +63,9 @@ See the `examples/` directory: ## Developer workflow - `./scripts/setup-dev.sh` - verify required local tools -- `./scripts/check.sh` - formatting, vet, and unit tests -- `./scripts/testacc.sh` - acceptance tests only +- `./scripts/check.sh` - formatting, vet, and unit tests (Tier A) +- `./scripts/test-integration.sh` - local mock-backed provider integration tests (Tier B) +- `./scripts/testacc.sh` - default: Tier B; live mode available with `--live` (Tier C) ### Standalone repository guarantee @@ -78,15 +80,35 @@ This repository is intentionally self-contained: go build ./... ``` -### Unit tests +## Testing model + +The provider now has three explicit test tiers: + +### Tier A: unit tests (fast, no network) + +Covers focused package behavior (client parsing, mappers, provider helper logic). ```bash +./scripts/check.sh go test ./... ``` -### Acceptance tests +### Tier B: local integration tests (mock Dynu API, no real credentials) -Acceptance tests are read-only and opt-in. +These tests use an `httptest` fake Dynu API server and run the Terraform provider end-to-end against deterministic fixtures. + +- No Dynu account required +- Dummy API key is used in test provider configuration +- Exercises provider wiring, schema/state mapping, hostname resolution flow, and diagnostic behavior + +```bash +./scripts/test-integration.sh +./scripts/testacc.sh +``` + +### Tier C: live acceptance tests (opt-in) + +These tests call the real Dynu API and are read-only. Required environment variables: - `TF_ACC=1` @@ -95,13 +117,13 @@ Required environment variables: Optional: - `DYNU_DOMAIN` (required for domain-specific acceptance tests such as `dynu_domain` and `dynu_dns_records`) -Run: - ```bash -TF_ACC=1 DYNU_API_KEY="your-dynu-api-key" DYNU_DOMAIN="www.example.com" ./scripts/testacc.sh +TF_ACC=1 DYNU_API_KEY="your-dynu-api-key" DYNU_DOMAIN="www.example.com" ./scripts/testacc.sh --live +# or +LIVE=1 TF_ACC=1 DYNU_API_KEY="your-dynu-api-key" ./scripts/testacc.sh ``` -If `DYNU_DOMAIN` is omitted, domain-specific tests skip cleanly. +If `DYNU_DOMAIN` is omitted, domain-specific live tests skip cleanly. ## CI @@ -110,7 +132,7 @@ GitHub Actions CI runs on push and pull requests and executes: - `go vet ./...` - `go test ./...` -Acceptance tests are intentionally excluded from default CI. +Live acceptance tests are intentionally excluded from default CI. ## Documentation @@ -124,5 +146,5 @@ Registry-style markdown docs are stored in `docs/`. ## Roadmap -Next planned milestone after this quality-hardening release: -- first writable resource (`dynu_dns_record`) with careful CRUD behavior and acceptance coverage. +Next planned milestone after this testing foundation: +- first writable resource (`dynu_dns_record`) with strict schema validation, import support, mock-first integration tests, and then live acceptance coverage. diff --git a/go.mod b/go.mod index 979d11d..2cfe612 100644 --- a/go.mod +++ b/go.mod @@ -1,35 +1,35 @@ module github.com/dynu/terraform-provider-dynu -go 1.23.0 +go 1.24.0 toolchain go1.24.3 require ( - github.com/hashicorp/terraform-plugin-framework v1.14.1 - github.com/hashicorp/terraform-plugin-framework-validators v0.17.0 + github.com/hashicorp/terraform-plugin-framework v1.16.1 + github.com/hashicorp/terraform-plugin-framework-validators v0.19.0 + github.com/hashicorp/terraform-plugin-go v0.29.0 ) require ( - github.com/fatih/color v1.13.0 // indirect + github.com/fatih/color v1.18.0 // indirect github.com/golang/protobuf v1.5.4 // indirect - github.com/hashicorp/go-hclog v1.5.0 // indirect - github.com/hashicorp/go-plugin v1.6.2 // indirect + github.com/hashicorp/go-hclog v1.6.3 // indirect + github.com/hashicorp/go-plugin v1.7.0 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect - github.com/hashicorp/terraform-plugin-go v0.26.0 // indirect - github.com/hashicorp/terraform-plugin-log v0.9.0 // indirect - github.com/hashicorp/terraform-registry-address v0.2.4 // indirect + github.com/hashicorp/terraform-plugin-log v0.10.0 // indirect + github.com/hashicorp/terraform-registry-address v0.4.0 // indirect github.com/hashicorp/terraform-svchost v0.1.1 // indirect - github.com/hashicorp/yamux v0.1.1 // indirect - github.com/mattn/go-colorable v0.1.12 // indirect - github.com/mattn/go-isatty v0.0.17 // indirect + github.com/hashicorp/yamux v0.1.2 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/go-testing-interface v1.14.1 // indirect - github.com/oklog/run v1.0.0 // indirect + github.com/oklog/run v1.1.0 // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect - golang.org/x/net v0.34.0 // indirect - golang.org/x/sys v0.29.0 // indirect - golang.org/x/text v0.21.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 // indirect - google.golang.org/grpc v1.69.4 // indirect - google.golang.org/protobuf v1.36.3 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/grpc v1.79.2 // indirect + google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/go.sum b/go.sum index 2049045..1430ee4 100644 --- a/go.sum +++ b/go.sum @@ -1,91 +1,101 @@ -github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA= -github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8= +github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= +github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hashicorp/go-hclog v1.5.0 h1:bI2ocEMgcVlz55Oj1xZNBsVi900c7II+fWDyV9o+13c= -github.com/hashicorp/go-hclog v1.5.0/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= -github.com/hashicorp/go-plugin v1.6.2 h1:zdGAEd0V1lCaU0u+MxWQhtSDQmahpkwOun8U8EiRVog= -github.com/hashicorp/go-plugin v1.6.2/go.mod h1:CkgLQ5CZqNmdL9U9JzM532t8ZiYQ35+pj3b1FD37R0Q= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-plugin v1.7.0 h1:YghfQH/0QmPNc/AZMTFE3ac8fipZyZECHdDPshfk+mA= +github.com/hashicorp/go-plugin v1.7.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/terraform-plugin-framework v1.14.1 h1:jaT1yvU/kEKEsxnbrn4ZHlgcxyIfjvZ41BLdlLk52fY= -github.com/hashicorp/terraform-plugin-framework v1.14.1/go.mod h1:xNUKmvTs6ldbwTuId5euAtg37dTxuyj3LHS3uj7BHQ4= -github.com/hashicorp/terraform-plugin-framework-validators v0.17.0 h1:0uYQcqqgW3BMyyve07WJgpKorXST3zkpzvrOnf3mpbg= -github.com/hashicorp/terraform-plugin-framework-validators v0.17.0/go.mod h1:VwdfgE/5Zxm43flraNa0VjcvKQOGVrcO4X8peIri0T0= -github.com/hashicorp/terraform-plugin-go v0.26.0 h1:cuIzCv4qwigug3OS7iKhpGAbZTiypAfFQmw8aE65O2M= -github.com/hashicorp/terraform-plugin-go v0.26.0/go.mod h1:+CXjuLDiFgqR+GcrM5a2E2Kal5t5q2jb0E3D57tTdNY= -github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= -github.com/hashicorp/terraform-plugin-log v0.9.0/go.mod h1:rKL8egZQ/eXSyDqzLUuwUYLVdlYeamldAHSxjUFADow= -github.com/hashicorp/terraform-registry-address v0.2.4 h1:JXu/zHB2Ymg/TGVCRu10XqNa4Sh2bWcqCNyKWjnCPJA= -github.com/hashicorp/terraform-registry-address v0.2.4/go.mod h1:tUNYTVyCtU4OIGXXMDp7WNcJ+0W1B4nmstVDgHMjfAU= +github.com/hashicorp/terraform-plugin-framework v1.16.1 h1:1+zwFm3MEqd/0K3YBB2v9u9DtyYHyEuhVOfeIXbteWA= +github.com/hashicorp/terraform-plugin-framework v1.16.1/go.mod h1:0xFOxLy5lRzDTayc4dzK/FakIgBhNf/lC4499R9cV4Y= +github.com/hashicorp/terraform-plugin-framework-validators v0.19.0 h1:Zz3iGgzxe/1XBkooZCewS0nJAaCFPFPHdNJd8FgE4Ow= +github.com/hashicorp/terraform-plugin-framework-validators v0.19.0/go.mod h1:GBKTNGbGVJohU03dZ7U8wHqc2zYnMUawgCN+gC0itLc= +github.com/hashicorp/terraform-plugin-go v0.29.0 h1:1nXKl/nSpaYIUBU1IG/EsDOX0vv+9JxAltQyDMpq5mU= +github.com/hashicorp/terraform-plugin-go v0.29.0/go.mod h1:vYZbIyvxyy0FWSmDHChCqKvI40cFTDGSb3D8D70i9GM= +github.com/hashicorp/terraform-plugin-log v0.10.0 h1:eu2kW6/QBVdN4P3Ju2WiB2W3ObjkAsyfBsL3Wh1fj3g= +github.com/hashicorp/terraform-plugin-log v0.10.0/go.mod h1:/9RR5Cv2aAbrqcTSdNmY1NRHP4E3ekrXRGjqORpXyB0= +github.com/hashicorp/terraform-registry-address v0.4.0 h1:S1yCGomj30Sao4l5BMPjTGZmCNzuv7/GDTDX99E9gTk= +github.com/hashicorp/terraform-registry-address v0.4.0/go.mod h1:LRS1Ay0+mAiRkUyltGT+UHWkIqTFvigGn/LbMshfflE= github.com/hashicorp/terraform-svchost v0.1.1 h1:EZZimZ1GxdqFRinZ1tpJwVxxt49xc/S52uzrw4x0jKQ= github.com/hashicorp/terraform-svchost v0.1.1/go.mod h1:mNsjQfZyf/Jhz35v6/0LWcv26+X7JPS+buii2c9/ctc= -github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= -github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= -github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c= -github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo= +github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= +github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= +github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= +github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= -github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= -github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= -github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw= -github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= +github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= -github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= -github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= -go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY= -go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE= -go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE= -go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY= -go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk= -go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0= -go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4JjxTeYusH7zMc= -go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8= -go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys= -go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A= -golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= -golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 h1:X58yt85/IXCx0Y3ZwN6sEIKZzQtDEYaBWrDvErdXrRE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI= -google.golang.org/grpc v1.69.4 h1:MF5TftSMkd8GLw/m0KM6V8CMOCY6NZ1NQDPGFgbTt4A= -google.golang.org/grpc v1.69.4/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= -google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU= -google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU= +google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/dynuclient/client_test.go b/internal/dynuclient/client_test.go index 25cef6c..05e169a 100644 --- a/internal/dynuclient/client_test.go +++ b/internal/dynuclient/client_test.go @@ -1,27 +1,19 @@ -package dynuclient +package dynuclient_test import ( "context" - "net/http" - "net/http/httptest" + "github.com/dynu/terraform-provider-dynu/internal/dynuclient" "strings" "testing" + + "github.com/dynu/terraform-provider-dynu/internal/testutil/fakedynu" ) func TestClientListDomainsSuccess(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/dns" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - if got := r.Header.Get("API-Key"); got != "test-key" { - t.Fatalf("unexpected api key header: %s", got) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"statusCode":200,"domains":[{"id":2,"name":"z.example.com"},{"id":1,"name":"a.example.com"}]}`)) - })) - defer ts.Close() + fake := fakedynu.NewServer() + defer fake.Close() - client := New("test-key", WithBaseURL(ts.URL), WithHTTPClient(ts.Client())) + client := dynuclient.New("test-key", dynuclient.WithBaseURL(fake.BaseURL()), dynuclient.WithHTTPClient(fake.Client())) domains, err := client.ListDomains(context.Background()) if err != nil { t.Fatalf("ListDomains() error = %v", err) @@ -32,13 +24,11 @@ func TestClientListDomainsSuccess(t *testing.T) { } func TestClientDoGetNon2xxStatus(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - _, _ = w.Write([]byte(`{"message":"nope"}`)) - })) - defer ts.Close() + fake := fakedynu.NewServer() + defer fake.Close() + fake.SetRawResponse("/dns", 401, `{"message":"nope"}`) - client := New("test-key", WithBaseURL(ts.URL), WithHTTPClient(ts.Client())) + client := dynuclient.New("test-key", dynuclient.WithBaseURL(fake.BaseURL()), dynuclient.WithHTTPClient(fake.Client())) _, err := client.ListDomains(context.Background()) if err == nil || !strings.Contains(err.Error(), "status 401") { t.Fatalf("expected status error, got %v", err) @@ -46,13 +36,11 @@ func TestClientDoGetNon2xxStatus(t *testing.T) { } func TestClientDoGetAPIExceptionPayload(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"statusCode":200,"exception":{"statusCode":400,"type":"Validation Exception","message":"bad hostname"}}`)) - })) - defer ts.Close() + fake := fakedynu.NewServer() + defer fake.Close() + fake.SetAPIError("/dns", fakedynu.APIError{HTTPStatus: 400, StatusCode: 400, Type: "Validation Exception", Message: "bad hostname"}) - client := New("test-key", WithBaseURL(ts.URL), WithHTTPClient(ts.Client())) + client := dynuclient.New("test-key", dynuclient.WithBaseURL(fake.BaseURL()), dynuclient.WithHTTPClient(fake.Client())) _, err := client.ListDomains(context.Background()) if err == nil || !strings.Contains(err.Error(), "Validation Exception") { t.Fatalf("expected API exception error, got %v", err) @@ -60,13 +48,11 @@ func TestClientDoGetAPIExceptionPayload(t *testing.T) { } func TestClientDoGetMalformedJSON(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"statusCode":200,"domains":[`)) - })) - defer ts.Close() + fake := fakedynu.NewServer() + defer fake.Close() + fake.SetRawResponse("/dns", 200, `{"statusCode":200,"domains":[`) - client := New("test-key", WithBaseURL(ts.URL), WithHTTPClient(ts.Client())) + client := dynuclient.New("test-key", dynuclient.WithBaseURL(fake.BaseURL()), dynuclient.WithHTTPClient(fake.Client())) _, err := client.ListDomains(context.Background()) if err == nil || !strings.Contains(err.Error(), "failed to decode dynu API response") { t.Fatalf("expected decode error, got %v", err) @@ -74,31 +60,25 @@ func TestClientDoGetMalformedJSON(t *testing.T) { } func TestClientGetRootDomainIncompleteResponse(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"statusCode":200,"id":0,"domainName":""}`)) - })) - defer ts.Close() + fake := fakedynu.NewServer() + defer fake.Close() + fake.SetRawResponse("/dns/getroot/www.a.example.com", 200, `{"statusCode":200,"id":0,"domainName":""}`) - client := New("test-key", WithBaseURL(ts.URL), WithHTTPClient(ts.Client())) - _, _, err := client.GetRootDomain(context.Background(), "www.example.com") + client := dynuclient.New("test-key", dynuclient.WithBaseURL(fake.BaseURL()), dynuclient.WithHTTPClient(fake.Client())) + _, _, err := client.GetRootDomain(context.Background(), "www.a.example.com") if err == nil || !strings.Contains(err.Error(), "incomplete root domain response") { t.Fatalf("expected incomplete response error, got %v", err) } } func TestClientGetRootDomainEscapesHostname(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !strings.Contains(r.RequestURI, "spaces%20in%20name.example.com") { - t.Fatalf("expected escaped hostname path, got %s", r.RequestURI) - } - _, _ = w.Write([]byte(`{"statusCode":200,"id":123,"domainName":"example.com"}`)) - })) - defer ts.Close() + fake := fakedynu.NewServer() + defer fake.Close() + fake.SetAPIError("/dns/getroot/spaces%20in%20name.example.com", fakedynu.APIError{HTTPStatus: 404, StatusCode: 404, Type: "Not Found", Message: "hostname not found"}) - client := New("test-key", WithBaseURL(ts.URL), WithHTTPClient(ts.Client())) + client := dynuclient.New("test-key", dynuclient.WithBaseURL(fake.BaseURL()), dynuclient.WithHTTPClient(fake.Client())) _, _, err := client.GetRootDomain(context.Background(), "spaces in name.example.com") - if err != nil { - t.Fatalf("expected nil error, got %v", err) + if err == nil || !strings.Contains(err.Error(), "hostname not found") { + t.Fatalf("expected hostname not found error, got %v", err) } } diff --git a/internal/provider/data_source_dns_records.go b/internal/provider/data_source_dns_records.go index fbd5541..8986160 100644 --- a/internal/provider/data_source_dns_records.go +++ b/internal/provider/data_source_dns_records.go @@ -100,18 +100,20 @@ func (d *dnsRecordsDataSource) Configure(_ context.Context, req datasource.Confi } func (d *dnsRecordsDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { - var state dnsRecordsDataSourceModel - resp.Diagnostics.Append(req.Config.Get(ctx, &state)...) + var config struct { + Hostname types.String `tfsdk:"hostname"` + } + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) if resp.Diagnostics.HasError() { return } - if state.Hostname.IsUnknown() || state.Hostname.IsNull() { + if config.Hostname.IsUnknown() || config.Hostname.IsNull() { resp.Diagnostics.AddAttributeError(path.Root("hostname"), "Invalid hostname", "The hostname must be known and non-null.") return } - domainID, domainName, err := d.clientProvider.client.GetRootDomain(ctx, state.Hostname.ValueString()) + domainID, domainName, err := d.clientProvider.client.GetRootDomain(ctx, config.Hostname.ValueString()) if err != nil { resp.Diagnostics.AddError("Unable to resolve Dynu domain from hostname", err.Error()) return @@ -125,9 +127,12 @@ func (d *dnsRecordsDataSource) Read(ctx context.Context, req datasource.ReadRequ sortDNSRecords(records) - state.DomainID = types.Int64Value(domainID) - state.DomainName = types.StringValue(domainName) - state.Records = make([]dnsRecordStateItem, 0, len(records)) + state := dnsRecordsDataSourceModel{ + Hostname: config.Hostname, + DomainID: types.Int64Value(domainID), + DomainName: types.StringValue(domainName), + Records: make([]dnsRecordStateItem, 0, len(records)), + } for _, record := range records { state.Records = append(state.Records, dnsRecordStateItem{ ID: types.Int64Value(record.ID), diff --git a/internal/provider/data_source_domain.go b/internal/provider/data_source_domain.go index 928d6b0..a48b6aa 100644 --- a/internal/provider/data_source_domain.go +++ b/internal/provider/data_source_domain.go @@ -73,18 +73,20 @@ func (d *domainDataSource) Configure(_ context.Context, req datasource.Configure } func (d *domainDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { - var state domainDataSourceModel - resp.Diagnostics.Append(req.Config.Get(ctx, &state)...) + var config struct { + Hostname types.String `tfsdk:"hostname"` + } + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) if resp.Diagnostics.HasError() { return } - if state.Hostname.IsUnknown() || state.Hostname.IsNull() { + if config.Hostname.IsUnknown() || config.Hostname.IsNull() { resp.Diagnostics.AddAttributeError(path.Root("hostname"), "Invalid hostname", "The hostname must be known and non-null.") return } - domainID, _, err := d.clientProvider.client.GetRootDomain(ctx, state.Hostname.ValueString()) + domainID, _, err := d.clientProvider.client.GetRootDomain(ctx, config.Hostname.ValueString()) if err != nil { resp.Diagnostics.AddError("Unable to resolve Dynu domain from hostname", err.Error()) return @@ -101,7 +103,10 @@ func (d *domainDataSource) Read(ctx context.Context, req datasource.ReadRequest, if resp.Diagnostics.HasError() { return } - state.Domain = domainObject + state := domainDataSourceModel{ + Hostname: config.Hostname, + Domain: domainObject, + } resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) } diff --git a/internal/provider/provider_integration_test.go b/internal/provider/provider_integration_test.go new file mode 100644 index 0000000..4171d73 --- /dev/null +++ b/internal/provider/provider_integration_test.go @@ -0,0 +1,169 @@ +package provider + +import ( + "context" + "strings" + "testing" + + "github.com/dynu/terraform-provider-dynu/internal/testutil/fakedynu" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestIntegrationDataSourceDomains(t *testing.T) { + fake := fakedynu.NewServer() + defer fake.Close() + + ds := NewDomainsDataSource().(*domainsDataSource) + configureDataSource(t, ds, fake.BaseURL()) + + var schemaResp datasource.SchemaResponse + ds.Schema(context.Background(), datasource.SchemaRequest{}, &schemaResp) + + resp := datasource.ReadResponse{State: tfsdk.State{Schema: schemaResp.Schema}} + ds.Read(context.Background(), datasource.ReadRequest{}, &resp) + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) + } + + var state domainsDataSourceModel + diags := resp.State.Get(context.Background(), &state) + if diags.HasError() { + t.Fatalf("state get diagnostics: %v", diags) + } + + if len(state.Domains) != 2 { + t.Fatalf("expected 2 domains, got %d", len(state.Domains)) + } + if state.Domains[0].Name.ValueString() != "a.example.com" { + t.Fatalf("expected sorted domain a.example.com first, got %q", state.Domains[0].Name.ValueString()) + } +} + +func TestIntegrationDataSourceDomain(t *testing.T) { + fake := fakedynu.NewServer() + defer fake.Close() + + ds := NewDomainDataSource().(*domainDataSource) + configureDataSource(t, ds, fake.BaseURL()) + + var schemaResp datasource.SchemaResponse + ds.Schema(context.Background(), datasource.SchemaRequest{}, &schemaResp) + + req := datasource.ReadRequest{ + Config: tfsdk.Config{ + Schema: schemaResp.Schema, + Raw: tftypes.NewValue(tftypes.Object{AttributeTypes: map[string]tftypes.Type{ + "hostname": tftypes.String, + }}, map[string]tftypes.Value{ + "hostname": tftypes.NewValue(tftypes.String, "www.a.example.com"), + }), + }, + } + resp := datasource.ReadResponse{State: tfsdk.State{Schema: schemaResp.Schema}} + ds.Read(context.Background(), req, &resp) + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) + } + + var state domainDataSourceModel + diags := resp.State.Get(context.Background(), &state) + if diags.HasError() { + t.Fatalf("state get diagnostics: %v", diags) + } + + var domainID int64 + diags = resp.State.GetAttribute(context.Background(), path.Root("domain").AtName("id"), &domainID) + if diags.HasError() { + t.Fatalf("domain.id decode diagnostics: %v", diags) + } + var domainName string + diags = resp.State.GetAttribute(context.Background(), path.Root("domain").AtName("name"), &domainName) + if diags.HasError() { + t.Fatalf("domain.name decode diagnostics: %v", diags) + } + if domainID != 1001 || domainName != "a.example.com" { + t.Fatalf("unexpected domain state: id=%d name=%q", domainID, domainName) + } +} + +func TestIntegrationDataSourceDNSRecords(t *testing.T) { + fake := fakedynu.NewServer() + defer fake.Close() + + ds := NewDNSRecordsDataSource().(*dnsRecordsDataSource) + configureDataSource(t, ds, fake.BaseURL()) + + var schemaResp datasource.SchemaResponse + ds.Schema(context.Background(), datasource.SchemaRequest{}, &schemaResp) + + req := datasource.ReadRequest{ + Config: tfsdk.Config{ + Schema: schemaResp.Schema, + Raw: tftypes.NewValue(tftypes.Object{AttributeTypes: map[string]tftypes.Type{ + "hostname": tftypes.String, + }}, map[string]tftypes.Value{ + "hostname": tftypes.NewValue(tftypes.String, "www.a.example.com"), + }), + }, + } + resp := datasource.ReadResponse{State: tfsdk.State{Schema: schemaResp.Schema}} + ds.Read(context.Background(), req, &resp) + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) + } + + var state dnsRecordsDataSourceModel + diags := resp.State.Get(context.Background(), &state) + if diags.HasError() { + t.Fatalf("state get diagnostics: %v", diags) + } + + if state.DomainID.ValueInt64() != 1001 || state.DomainName.ValueString() != "a.example.com" { + t.Fatalf("unexpected domain resolution: domain_id=%d domain_name=%q", state.DomainID.ValueInt64(), state.DomainName.ValueString()) + } + if len(state.Records) != 2 || state.Records[0].ID.ValueInt64() != 10 { + t.Fatalf("unexpected records state: %#v", state.Records) + } +} + +func TestIntegrationDataSourceDiagnosticsFromAPIError(t *testing.T) { + fake := fakedynu.NewServer() + defer fake.Close() + fake.SetAPIError("/dns/getroot/www.a.example.com", fakedynu.APIError{HTTPStatus: 400, StatusCode: 400, Type: "Validation Exception", Message: "bad hostname"}) + + ds := NewDomainDataSource().(*domainDataSource) + configureDataSource(t, ds, fake.BaseURL()) + + var schemaResp datasource.SchemaResponse + ds.Schema(context.Background(), datasource.SchemaRequest{}, &schemaResp) + + req := datasource.ReadRequest{Config: tfsdk.Config{ + Schema: schemaResp.Schema, + Raw: tftypes.NewValue(tftypes.Object{AttributeTypes: map[string]tftypes.Type{"hostname": tftypes.String}}, map[string]tftypes.Value{ + "hostname": tftypes.NewValue(tftypes.String, "www.a.example.com"), + }), + }} + resp := datasource.ReadResponse{State: tfsdk.State{Schema: schemaResp.Schema}} + ds.Read(context.Background(), req, &resp) + if !resp.Diagnostics.HasError() { + t.Fatal("expected diagnostics error") + } + if !strings.Contains(resp.Diagnostics[0].Summary(), "Unable to resolve Dynu domain from hostname") { + t.Fatalf("unexpected diagnostics summary: %s", resp.Diagnostics[0].Summary()) + } +} + +func configureDataSource(t *testing.T, ds datasource.DataSourceWithConfigure, baseURL string) { + t.Helper() + resp := datasource.ConfigureResponse{} + ds.Configure(context.Background(), datasource.ConfigureRequest{ + ProviderData: &providerData{client: newDynuClient("dummy-local-key", types.StringValue(baseURL))}, + }, &resp) + if resp.Diagnostics.HasError() { + t.Fatalf("configure diagnostics: %v", resp.Diagnostics) + } +} diff --git a/internal/testutil/fakedynu/server.go b/internal/testutil/fakedynu/server.go new file mode 100644 index 0000000..70ed499 --- /dev/null +++ b/internal/testutil/fakedynu/server.go @@ -0,0 +1,204 @@ +package fakedynu + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync" + + "github.com/dynu/terraform-provider-dynu/internal/dynuclient" +) + +type RootDomain struct { + ID int64 + Hostname string + DomainName string + Node string +} + +type APIError struct { + HTTPStatus int + StatusCode int + Type string + Message string +} + +type Fixture struct { + Domains []dynuclient.Domain + RootsByHostname map[string]RootDomain + RecordsByDomain map[int64][]dynuclient.DNSRecord +} + +type Server struct { + *httptest.Server + + mu sync.RWMutex + fixture Fixture + errors map[string]APIError + rawPayload map[string]rawResponse +} + +type rawResponse struct { + httpStatus int + body string +} + +func NewServer() *Server { + s := &Server{ + errors: map[string]APIError{}, + rawPayload: map[string]rawResponse{}, + fixture: Fixture{ + Domains: []dynuclient.Domain{ + {ID: 2002, Name: "z.example.com", UnicodeName: "z.example.com", TTL: 60, CreatedOn: "2024-01-03T00:00:00", UpdatedOn: "2024-01-04T00:00:00"}, + {ID: 1001, Name: "a.example.com", UnicodeName: "a.example.com", TTL: 120, CreatedOn: "2024-01-01T00:00:00", UpdatedOn: "2024-01-02T00:00:00"}, + }, + RootsByHostname: map[string]RootDomain{ + "www.a.example.com": {ID: 1001, Hostname: "www.a.example.com", DomainName: "a.example.com", Node: "www"}, + "api.a.example.com": {ID: 1001, Hostname: "api.a.example.com", DomainName: "a.example.com", Node: "api"}, + }, + RecordsByDomain: map[int64][]dynuclient.DNSRecord{ + 1001: { + {ID: 20, DomainID: 1001, DomainName: "a.example.com", NodeName: "www", Hostname: "www.a.example.com", RecordType: "TXT", TTL: 90, State: true, Content: "v=spf1", UpdatedOn: "2024-01-03T11:00:00"}, + {ID: 10, DomainID: 1001, DomainName: "a.example.com", NodeName: "www", Hostname: "www.a.example.com", RecordType: "A", TTL: 30, State: true, Content: "203.0.113.5", UpdatedOn: "2024-01-03T10:00:00"}, + }, + }, + }, + } + + s.Server = httptest.NewServer(http.HandlerFunc(s.serveHTTP)) + return s +} + +func (s *Server) BaseURL() string { + return s.URL +} + +func (s *Server) SetFixture(fixture Fixture) { + s.mu.Lock() + defer s.mu.Unlock() + s.fixture = fixture +} + +func (s *Server) SetAPIError(path string, apiErr APIError) { + s.mu.Lock() + defer s.mu.Unlock() + s.errors[path] = apiErr +} + +func (s *Server) SetRawResponse(path string, httpStatus int, body string) { + s.mu.Lock() + defer s.mu.Unlock() + s.rawPayload[path] = rawResponse{httpStatus: httpStatus, body: body} +} + +func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + s.mu.RLock() + defer s.mu.RUnlock() + + if raw, ok := s.rawPayload[r.URL.Path]; ok { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(raw.httpStatus) + _, _ = w.Write([]byte(raw.body)) + return + } + + if apiErr, ok := s.errors[r.URL.Path]; ok { + s.writeAPIError(w, apiErr) + return + } + + switch { + case r.URL.Path == "/dns": + s.writeJSON(w, http.StatusOK, map[string]any{"statusCode": 200, "domains": s.fixture.Domains}) + return + case strings.HasPrefix(r.URL.Path, "/dns/getroot/"): + hostname := strings.TrimPrefix(r.URL.Path, "/dns/getroot/") + root, ok := s.fixture.RootsByHostname[hostname] + if !ok { + s.writeAPIError(w, APIError{HTTPStatus: http.StatusNotFound, StatusCode: 404, Type: "Not Found", Message: "hostname not found"}) + return + } + s.writeJSON(w, http.StatusOK, map[string]any{ + "statusCode": 200, + "id": root.ID, + "hostname": root.Hostname, + "domainName": root.DomainName, + "node": root.Node, + }) + return + case strings.HasSuffix(r.URL.Path, "/record"): + domainID, err := domainIDFromPath(strings.TrimSuffix(r.URL.Path, "/record")) + if err != nil { + s.writeAPIError(w, APIError{HTTPStatus: http.StatusBadRequest, StatusCode: 400, Type: "Validation Exception", Message: err.Error()}) + return + } + records, ok := s.fixture.RecordsByDomain[domainID] + if !ok { + records = []dynuclient.DNSRecord{} + } + s.writeJSON(w, http.StatusOK, map[string]any{"statusCode": 200, "dnsRecords": records}) + return + default: + domainID, err := domainIDFromPath(r.URL.Path) + if err != nil { + s.writeAPIError(w, APIError{HTTPStatus: http.StatusNotFound, StatusCode: 404, Type: "Not Found", Message: "endpoint not found"}) + return + } + for _, domain := range s.fixture.Domains { + if domain.ID == domainID { + payload := map[string]any{"statusCode": 200} + b, _ := json.Marshal(domain) + _ = json.Unmarshal(b, &payload) + s.writeJSON(w, http.StatusOK, payload) + return + } + } + s.writeAPIError(w, APIError{HTTPStatus: http.StatusNotFound, StatusCode: 404, Type: "Not Found", Message: "domain not found"}) + } +} + +func domainIDFromPath(path string) (int64, error) { + trimmed := strings.TrimPrefix(path, "/dns/") + if trimmed == path { + return 0, fmt.Errorf("invalid domain path: %s", path) + } + + id, err := strconv.ParseInt(trimmed, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid domain id: %s", trimmed) + } + return id, nil +} + +func (s *Server) writeAPIError(w http.ResponseWriter, apiErr APIError) { + if apiErr.HTTPStatus == 0 { + apiErr.HTTPStatus = http.StatusBadRequest + } + if apiErr.StatusCode == 0 { + apiErr.StatusCode = apiErr.HTTPStatus + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(apiErr.HTTPStatus) + _ = json.NewEncoder(w).Encode(map[string]any{ + "statusCode": apiErr.StatusCode, + "exception": map[string]any{ + "statusCode": apiErr.StatusCode, + "type": apiErr.Type, + "message": apiErr.Message, + }, + }) +} + +func (s *Server) writeJSON(w http.ResponseWriter, status int, payload any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(payload) +} diff --git a/scripts/test-integration.sh b/scripts/test-integration.sh new file mode 100755 index 0000000..2599ba5 --- /dev/null +++ b/scripts/test-integration.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "[test-integration] running mock-backed provider integration tests" +go test ./internal/provider -run '^TestIntegration' -count=1 -v diff --git a/scripts/testacc.sh b/scripts/testacc.sh index b083e58..252c148 100755 --- a/scripts/testacc.sh +++ b/scripts/testacc.sh @@ -1,13 +1,23 @@ #!/usr/bin/env bash set -euo pipefail +mode="mock" +if [[ "${1:-}" == "--live" || "${LIVE:-}" == "1" ]]; then + mode="live" +fi + +if [[ "${mode}" == "mock" ]]; then + echo "[testacc] no --live flag detected; running local mock-backed integration tests" + exec "$(dirname "$0")/test-integration.sh" +fi + if [[ "${TF_ACC:-}" != "1" ]]; then - echo "[testacc][error] TF_ACC must be set to 1" >&2 + echo "[testacc][error] live mode requires TF_ACC=1" >&2 exit 1 fi if [[ -z "${DYNU_API_KEY:-}" ]]; then - echo "[testacc][error] DYNU_API_KEY must be set" >&2 + echo "[testacc][error] live mode requires DYNU_API_KEY" >&2 exit 1 fi @@ -15,5 +25,5 @@ if [[ -z "${DYNU_DOMAIN:-}" ]]; then echo "[testacc][warn] DYNU_DOMAIN not set; domain-specific acceptance tests will skip" fi -echo "[testacc] running acceptance tests" +echo "[testacc] running live acceptance tests" go test ./internal/provider -run '^TestAcc' -count=1 -v