From feb573d2f68999ec6054697289707d0c43919b58 Mon Sep 17 00:00:00 2001 From: Peter Alserda Date: Tue, 21 Jul 2026 11:14:55 +0200 Subject: [PATCH 01/11] feat: resolve a zone's virtual name server by origin Add ResolveVirtualNameServer to the client package, so origin-to-vns resolution stays AutoDNS knowledge inside this library rather than being reimplemented by the Terraform provider that consumes it. --- CHANGELOG.md | 8 +++++ client/zone.go | 41 ++++++++++++++++++++++ client/zone_test.go | 76 ++++++++++++++++++++++++++++++++++++++++ tests/zone_stage_test.go | 29 +++++++++++++++ tests/zone_test.go | 39 +++++++++++++++++++++ 5 files changed, 193 insertions(+) create mode 100644 client/zone_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 48880af..87d0dc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Added + +- `client` package: `ResolveVirtualNameServer` resolves a zone's `virtualNameServer` from its origin via zone search, returning `ErrZoneNotFound` when no zone matches and a descriptive error when more than one zone shares the origin. Search results are filtered to an exact origin match before the count checks, so a server whose `name EQUAL` search matched loosely cannot make resolution wrongly ambiguous or resolve to the wrong zone. + +## [0.0.2] - 2026-07-20 + ### Fixed - `StreamRecords` now posts to `/zone/{origin}/_stream`: the real API keys the stream endpoint by origin alone and returns 404 for the previously used `/zone/{origin}/{virtualNameServer}/_stream` path (verified against api.autodns.com on 2026-07-20). The unused `virtualNameServer` parameter is dropped from the signature (breaking), and `fakeautodns` serves the corrected route. +## [0.0.1] - 2026-07-17 + ### Added - `client` package: `New` with functional options (`WithEndpoint`, `WithHTTPClient`, `WithMaxRetries`, `WithRetryWait`), HTTP Basic Auth plus `X-Domainrobot-Context` header, and retries for 429/500/502/503/504 with exponential backoff, jitter, and `Retry-After` support (seconds or HTTP-date form, honored on any retried response). diff --git a/client/zone.go b/client/zone.go index 002c217..bf0d956 100644 --- a/client/zone.go +++ b/client/zone.go @@ -102,6 +102,47 @@ func (c *Client) DeleteZone(ctx context.Context, origin, virtualNameServer strin return nil } +// ResolveVirtualNameServer returns the virtualNameServer of the zone +// with the given origin, found via zone search. It returns an error +// matching ErrZoneNotFound when no zone with that origin exists, and an +// error naming the origin and the count when more than one zone +// matches: zone identity is origin+virtualNameServer, so more than one +// zone sharing an origin is genuinely ambiguous, not a pick-first guess. +// +// Search results are filtered to an exact origin match before the count +// checks, which makes resolution independent of how the server +// interprets the name search: whether it matches loosely (e.g. +// returning subdomains of origin) or only exactly, only exact-origin +// results ever reach the count checks. +func (c *Client) ResolveVirtualNameServer(ctx context.Context, origin string) (string, error) { + zones, err := c.SearchZones(ctx, []models.Filter{ + { + Key: "name", + Value: origin, + Operator: "EQUAL", + }, + }) + if err != nil { + return "", fmt.Errorf("resolving virtual name server for %s: %w", origin, err) + } + + matched := make([]models.Zone, 0, len(zones)) + for _, zone := range zones { + if zone.Origin == origin { + matched = append(matched, zone) + } + } + + if len(matched) == 0 { + return "", fmt.Errorf("resolving virtual name server for %s: %w", origin, ErrZoneNotFound) + } + if len(matched) > 1 { + return "", fmt.Errorf("resolving virtual name server for %s: %d zones share this origin, resolution is ambiguous", origin, len(matched)) + } + + return matched[0].VirtualNameServer, nil +} + // zonePath builds the /zone/{origin}/{virtualNameServer} path with both // segments escaped. func zonePath(origin, virtualNameServer string) string { diff --git a/client/zone_test.go b/client/zone_test.go new file mode 100644 index 0000000..f70fc81 --- /dev/null +++ b/client/zone_test.go @@ -0,0 +1,76 @@ +package client_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/alserda-nl/go-autodns/client" +) + +// TestResolveVirtualNameServer_FiltersToExactOrigin drives the defensive +// origin filter that fakeautodns (which matches EQUAL exactly) cannot +// exercise: a search server that matched loosely could return a +// near-miss zone whose origin is not the searched origin. The filter +// must drop those before the count checks, so a lone exact match still +// resolves, a near miss alongside it does not make resolution +// ambiguous, and two zones that genuinely share the searched origin +// (distinguished only by virtualNameServer) are reported as ambiguous +// rather than resolved to either one. +func TestResolveVirtualNameServer_FiltersToExactOrigin(t *testing.T) { + cases := []struct { + name string + origin string + searchData string + expectedVNS string + expectedErrorSubstring string // "" means no error expected + }{ + { + name: "exact match alongside a near miss resolves to the exact match", + origin: "example.com", + searchData: `[{"origin":"example.com","virtualNameServer":"a.ns.example.net"},{"origin":"www.example.com","virtualNameServer":"b.ns.example.net"}]`, + expectedVNS: "a.ns.example.net", + expectedErrorSubstring: "", + }, + { + name: "only a near miss returned resolves to not found", + origin: "example.com", + searchData: `[{"origin":"www.example.com","virtualNameServer":"b.ns.example.net"}]`, + expectedVNS: "", + expectedErrorSubstring: "zone not found", + }, + { + name: "two zones share the same origin is ambiguous", + origin: "example.com", + searchData: `[{"origin":"example.com","virtualNameServer":"a.ns.example.net"},{"origin":"example.com","virtualNameServer":"b.ns.example.net"}]`, + expectedVNS: "", + expectedErrorSubstring: "ambiguous", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"status":{"type":"SUCCESS"},"data":` + tc.searchData + `}`)) + })) + t.Cleanup(server.Close) + apiClient, err := client.New("user", "secret", "4", client.WithEndpoint(server.URL)) + require.NoError(t, err) + + actualVNS, err := apiClient.ResolveVirtualNameServer(context.Background(), tc.origin) + + if tc.expectedErrorSubstring != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErrorSubstring) + return + } + + require.NoError(t, err) + assert.Equal(t, tc.expectedVNS, actualVNS) + }) + } +} diff --git a/tests/zone_stage_test.go b/tests/zone_stage_test.go index 8bfb9a3..e15bde3 100644 --- a/tests/zone_stage_test.go +++ b/tests/zone_stage_test.go @@ -2,6 +2,7 @@ package e2e import ( "context" + "fmt" "testing" "github.com/stretchr/testify/assert" @@ -24,6 +25,7 @@ type zoneStage struct { searchResult []models.Zone zoneResult *models.Zone + vnsResult string err error } @@ -60,6 +62,15 @@ func (s *zoneStage) seeded_zones(origins ...string) *zoneStage { return s } +// two_seeded_zones_sharing_origin seeds two zones with the same origin +// but different virtual name servers, so resolution by origin alone is +// genuinely ambiguous. +func (s *zoneStage) two_seeded_zones_sharing_origin(origin, virtualNameServerA, virtualNameServerB string) *zoneStage { + s.fake.SeedZone(models.Zone{Origin: origin, VirtualNameServer: virtualNameServerA}) + s.fake.SeedZone(models.Zone{Origin: origin, VirtualNameServer: virtualNameServerB}) + return s +} + // a_seeded_zone_with_soa_and_mx seeds a zone carrying an SOA block and an // MX record with a non-zero Pref, so a get round trip proves both fields // survive the JSON mapping in both directions. @@ -148,6 +159,11 @@ func (s *zoneStage) the_zone_is_deleted(origin string) *zoneStage { return s } +func (s *zoneStage) the_vns_is_resolved(origin string) *zoneStage { + s.vnsResult, s.err = s.apiClient.ResolveVirtualNameServer(context.Background(), origin) + return s +} + // --- THEN --- func (s *zoneStage) the_operation_succeeds() *zoneStage { @@ -204,6 +220,19 @@ func (s *zoneStage) the_error_is_zone_not_found() *zoneStage { return s } +func (s *zoneStage) the_resolved_vns_is(virtualNameServer string) *zoneStage { + s.require.NoError(s.err) + s.assert.Equal(virtualNameServer, s.vnsResult) + return s +} + +func (s *zoneStage) the_error_is_ambiguous_naming(origin string, count int) *zoneStage { + s.require.Error(s.err) + s.assert.Contains(s.err.Error(), origin) + s.assert.Contains(s.err.Error(), fmt.Sprintf("%d zones", count)) + return s +} + func (s *zoneStage) the_error_is_api_error_with_code(code string) *zoneStage { var apiErr *client.APIError s.require.ErrorAs(s.err, &apiErr) diff --git a/tests/zone_test.go b/tests/zone_test.go index d645380..93d8851 100644 --- a/tests/zone_test.go +++ b/tests/zone_test.go @@ -162,3 +162,42 @@ func TestZone_DeleteMissingZoneReturnsNotFound(t *testing.T) { then. the_error_is_zone_not_found() } + +func TestZone_ResolveVirtualNameServerReturnsTheSeededVNS(t *testing.T) { + given, when, then := newZoneStage(t) + + given. + a_seeded_zone("example.com") + + when. + the_vns_is_resolved("example.com") + + then. + the_resolved_vns_is(testVirtualNameServer) +} + +func TestZone_ResolveVirtualNameServerMissingZoneReturnsNotFound(t *testing.T) { + given, when, then := newZoneStage(t) + + given. + a_seeded_zone("example.com") + + when. + the_vns_is_resolved("missing.example.com") + + then. + the_error_is_zone_not_found() +} + +func TestZone_ResolveVirtualNameServerAmbiguousOriginReturnsError(t *testing.T) { + given, when, then := newZoneStage(t) + + given. + two_seeded_zones_sharing_origin("example.com", "a.ns.example.net", "b.ns.example.net") + + when. + the_vns_is_resolved("example.com") + + then. + the_error_is_ambiguous_naming("example.com", 2) +} From f90d80e5f24ea0c81de5eff43cac9f61807e8264 Mon Sep 17 00:00:00 2001 From: Peter Alserda Date: Wed, 22 Jul 2026 21:06:32 +0200 Subject: [PATCH 02/11] feat: add UpdateRecords to target one zone by virtual name server --- CHANGELOG.md | 1 + client/record.go | 15 +++++++++++++++ client/record_test.go | 35 +++++++++++++++++++++++++++++++++++ models/record.go | 14 ++++++++++++++ 4 files changed, 65 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87d0dc9..0138ba4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added - `client` package: `ResolveVirtualNameServer` resolves a zone's `virtualNameServer` from its origin via zone search, returning `ErrZoneNotFound` when no zone matches and a descriptive error when more than one zone shares the origin. Search results are filtered to an exact origin match before the count checks, so a server whose `name EQUAL` search matched loosely cannot make resolution wrongly ambiguous or resolve to the wrong zone. +- `client` package: `UpdateRecords(ctx, origin, virtualNameServer, adds, rems)` applies record additions and removals to one specific zone via `PATCH /zone/{origin}/{virtualNameServer}`, so an origin with more than one zone can be managed. `models.ZonePatchRequest` is its request body. ## [0.0.2] - 2026-07-20 diff --git a/client/record.go b/client/record.go index cd8f089..9ecd52a 100644 --- a/client/record.go +++ b/client/record.go @@ -24,3 +24,18 @@ func (c *Client) StreamRecords(ctx context.Context, origin string, adds, rems [] return nil } + +// UpdateRecords atomically applies record additions and removals to one +// zone via PATCH /zone/{origin}/{virtualNameServer}. AutoDNS has no +// per-record endpoints; this is the vns-targeted incremental route. The +// origin-only _stream endpoint cannot address one of several zones +// sharing an origin, so callers pass the specific virtualNameServer. It +// returns an error matching ErrZoneNotFound when the zone does not exist. +func (c *Client) UpdateRecords(ctx context.Context, origin, virtualNameServer string, adds, rems []models.ResourceRecord) error { + requestBody := models.ZonePatchRequest{ResourceRecordsAdd: adds, ResourceRecordsRem: rems} + if _, err := c.doRequest(ctx, http.MethodPatch, zonePath(origin, virtualNameServer), requestBody); err != nil { + return fmt.Errorf("updating records: %w", err) + } + + return nil +} diff --git a/client/record_test.go b/client/record_test.go index fb590a3..55d8232 100644 --- a/client/record_test.go +++ b/client/record_test.go @@ -46,3 +46,38 @@ func TestStreamRecords_Happy_PostsOriginOnlyPath(t *testing.T) { assert.Equal(t, http.MethodPost, gotMethod) assert.Equal(t, "/zone/example.com/_stream", gotPath) } + +// TestUpdateRecords_Happy_PatchesVNSPath pins the production route: the +// real API applies record adds and removals to one specific zone via +// PATCH /zone/{origin}/{virtualNameServer}. The origin-only _stream +// endpoint cannot target one of several zones sharing an origin, so the +// vns segment is load-bearing here. +func TestUpdateRecords_Happy_PatchesVNSPath(t *testing.T) { + var gotMethod, gotPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod, gotPath = r.Method, r.URL.Path + _, _ = w.Write([]byte(`{"status":{"type":"SUCCESS"}}`)) + })) + t.Cleanup(server.Close) + apiClient, err := client.New("user", "secret", "4", client.WithEndpoint(server.URL)) + require.NoError(t, err) + + err = apiClient.UpdateRecords( + context.Background(), + "example.com", + "a.ns.example.net", + []models.ResourceRecord{ + { + Name: "www", + Type: "A", + Value: "192.0.2.10", + TTL: 600, + }, + }, + nil, + ) + + require.NoError(t, err) + assert.Equal(t, http.MethodPatch, gotMethod) + assert.Equal(t, "/zone/example.com/a.ns.example.net", gotPath) +} diff --git a/models/record.go b/models/record.go index fce3b31..757c0f1 100644 --- a/models/record.go +++ b/models/record.go @@ -27,3 +27,17 @@ type RecordStreamRequest struct { // name, type, and value. Rems []ResourceRecord `json:"rems"` } + +// ZonePatchRequest is the JSON body of the zone record-update endpoint +// (PATCH /zone/{origin}/{virtualNameServer}), which atomically adds and +// removes records on one specific zone. It replaces RecordStreamRequest: +// the old _stream endpoint was keyed by origin alone and could not target +// one of several zones sharing an origin. +type ZonePatchRequest struct { + // ResourceRecordsAdd are the records to add to the zone. + ResourceRecordsAdd []ResourceRecord `json:"resourceRecordsAdd,omitempty"` + // ResourceRecordsRem are the records to remove. The API matches them + // on name, type, and value (as the old _stream endpoint did); this is + // confirmed on a throwaway zone before release, see the plan's Phase C. + ResourceRecordsRem []ResourceRecord `json:"resourceRecordsRem,omitempty"` +} From 36b16f48b2986774c1ced203ce7aa204f3b8471f Mon Sep 17 00:00:00 2001 From: Peter Alserda Date: Wed, 22 Jul 2026 21:11:49 +0200 Subject: [PATCH 03/11] feat: add ErrAmbiguousZone sentinel for ambiguous origins --- CHANGELOG.md | 1 + client/errors.go | 6 ++++++ client/zone.go | 17 +++++++++++++---- client/zone_test.go | 9 ++++++++- 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0138ba4..f61788f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - `client` package: `ResolveVirtualNameServer` resolves a zone's `virtualNameServer` from its origin via zone search, returning `ErrZoneNotFound` when no zone matches and a descriptive error when more than one zone shares the origin. Search results are filtered to an exact origin match before the count checks, so a server whose `name EQUAL` search matched loosely cannot make resolution wrongly ambiguous or resolve to the wrong zone. - `client` package: `UpdateRecords(ctx, origin, virtualNameServer, adds, rems)` applies record additions and removals to one specific zone via `PATCH /zone/{origin}/{virtualNameServer}`, so an origin with more than one zone can be managed. `models.ZonePatchRequest` is its request body. +- `client` package: `ErrAmbiguousZone` sentinel, returned (via `errors.Is`) by `ResolveVirtualNameServer` when more than one zone shares an origin, so callers can distinguish an ambiguous origin from any other resolution failure. ## [0.0.2] - 2026-07-20 diff --git a/client/errors.go b/client/errors.go index e5e5110..3967ecb 100644 --- a/client/errors.go +++ b/client/errors.go @@ -14,6 +14,12 @@ var ( // ErrZoneNotFound is returned by zone operations when the API // reports that the requested zone does not exist. ErrZoneNotFound = errors.New("autodns: zone not found") + + // ErrAmbiguousZone is returned by ResolveVirtualNameServer when more than + // one zone shares the given origin, so the origin alone does not identify + // a single zone. Callers that can accept a virtual name server from their + // own configuration should ask for one instead of resolving. + ErrAmbiguousZone = errors.New("zone origin is ambiguous") ) // APIError is returned when the AutoDNS response envelope reports diff --git a/client/zone.go b/client/zone.go index bf0d956..2818892 100644 --- a/client/zone.go +++ b/client/zone.go @@ -7,6 +7,8 @@ import ( "fmt" "net/http" "net/url" + "slices" + "strings" "github.com/alserda-nl/go-autodns/models" ) @@ -105,9 +107,10 @@ func (c *Client) DeleteZone(ctx context.Context, origin, virtualNameServer strin // ResolveVirtualNameServer returns the virtualNameServer of the zone // with the given origin, found via zone search. It returns an error // matching ErrZoneNotFound when no zone with that origin exists, and an -// error naming the origin and the count when more than one zone -// matches: zone identity is origin+virtualNameServer, so more than one -// zone sharing an origin is genuinely ambiguous, not a pick-first guess. +// error matching ErrAmbiguousZone, naming the candidate virtual name +// servers, when more than one zone matches: zone identity is +// origin+virtualNameServer, so more than one zone sharing an origin is +// genuinely ambiguous, not a pick-first guess. // // Search results are filtered to an exact origin match before the count // checks, which makes resolution independent of how the server @@ -137,7 +140,13 @@ func (c *Client) ResolveVirtualNameServer(ctx context.Context, origin string) (s return "", fmt.Errorf("resolving virtual name server for %s: %w", origin, ErrZoneNotFound) } if len(matched) > 1 { - return "", fmt.Errorf("resolving virtual name server for %s: %d zones share this origin, resolution is ambiguous", origin, len(matched)) + servers := make([]string, 0, len(matched)) + for _, zone := range matched { + servers = append(servers, zone.VirtualNameServer) + } + slices.Sort(servers) + + return "", fmt.Errorf("resolving virtual name server for %s: %w (%d zones share this origin: %s)", origin, ErrAmbiguousZone, len(matched), strings.Join(servers, ", ")) } return matched[0].VirtualNameServer, nil diff --git a/client/zone_test.go b/client/zone_test.go index f70fc81..50d19a1 100644 --- a/client/zone_test.go +++ b/client/zone_test.go @@ -28,6 +28,7 @@ func TestResolveVirtualNameServer_FiltersToExactOrigin(t *testing.T) { searchData string expectedVNS string expectedErrorSubstring string // "" means no error expected + expectedSentinel error // nil means no sentinel to check }{ { name: "exact match alongside a near miss resolves to the exact match", @@ -35,6 +36,7 @@ func TestResolveVirtualNameServer_FiltersToExactOrigin(t *testing.T) { searchData: `[{"origin":"example.com","virtualNameServer":"a.ns.example.net"},{"origin":"www.example.com","virtualNameServer":"b.ns.example.net"}]`, expectedVNS: "a.ns.example.net", expectedErrorSubstring: "", + expectedSentinel: nil, }, { name: "only a near miss returned resolves to not found", @@ -42,13 +44,15 @@ func TestResolveVirtualNameServer_FiltersToExactOrigin(t *testing.T) { searchData: `[{"origin":"www.example.com","virtualNameServer":"b.ns.example.net"}]`, expectedVNS: "", expectedErrorSubstring: "zone not found", + expectedSentinel: client.ErrZoneNotFound, }, { name: "two zones share the same origin is ambiguous", origin: "example.com", searchData: `[{"origin":"example.com","virtualNameServer":"a.ns.example.net"},{"origin":"example.com","virtualNameServer":"b.ns.example.net"}]`, expectedVNS: "", - expectedErrorSubstring: "ambiguous", + expectedErrorSubstring: "b.ns.example.net", + expectedSentinel: client.ErrAmbiguousZone, }, } @@ -66,6 +70,9 @@ func TestResolveVirtualNameServer_FiltersToExactOrigin(t *testing.T) { if tc.expectedErrorSubstring != "" { require.Error(t, err) assert.Contains(t, err.Error(), tc.expectedErrorSubstring) + if tc.expectedSentinel != nil { + require.ErrorIs(t, err, tc.expectedSentinel) + } return } From c8c8a858ebdfa25fcd2cfc08beceb8528445baa0 Mon Sep 17 00:00:00 2001 From: Peter Alserda Date: Wed, 22 Jul 2026 21:16:49 +0200 Subject: [PATCH 04/11] feat: fakeautodns serves the vns-targeted PATCH record route --- fakeautodns/fakeautodns.go | 1 + fakeautodns/fakeautodns_test.go | 19 +++++++++++++++++++ fakeautodns/handlers.go | 28 ++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/fakeautodns/fakeautodns.go b/fakeautodns/fakeautodns.go index bb0cddc..05d7368 100644 --- a/fakeautodns/fakeautodns.go +++ b/fakeautodns/fakeautodns.go @@ -59,6 +59,7 @@ func New(t *testing.T) *Server { mux.HandleFunc("GET /zone/{origin}/{virtualNameServer}", server.handleGetZone) mux.HandleFunc("PUT /zone/{origin}/{virtualNameServer}", server.handleUpdateZone) mux.HandleFunc("DELETE /zone/{origin}/{virtualNameServer}", server.handleDeleteZone) + mux.HandleFunc("PATCH /zone/{origin}/{virtualNameServer}", server.handleUpdateRecords) mux.HandleFunc("POST /zone/{origin}/_stream", server.handleStreamRecords) server.Server = httptest.NewServer(server.intercept(mux)) diff --git a/fakeautodns/fakeautodns_test.go b/fakeautodns/fakeautodns_test.go index 8c12233..096fa4e 100644 --- a/fakeautodns/fakeautodns_test.go +++ b/fakeautodns/fakeautodns_test.go @@ -172,6 +172,25 @@ func TestStreamEndpoint_Happy_MutatesState(t *testing.T) { assert.Equal(t, "www", zone.ResourceRecords[0].Name) } +func TestServer_UpdateRecordsTargetsOneZoneOfSharedOrigin(t *testing.T) { + fake := fakeautodns.New(t) + fake.SeedZone(models.Zone{Origin: "example.com", VirtualNameServer: "a.ns.example.net"}) + fake.SeedZone(models.Zone{Origin: "example.com", VirtualNameServer: "b.ns.example.net"}) + + statusCode, _ := doJSON(t, http.MethodPatch, fake.URL+"/zone/example.com/a.ns.example.net", `{ + "resourceRecordsAdd": [{"name": "www", "type": "A", "value": "192.0.2.10", "ttl": 600}] + }`) + + assert.Equal(t, http.StatusOK, statusCode) + targeted, ok := fake.Zone("example.com", "a.ns.example.net") + require.True(t, ok) + require.Len(t, targeted.ResourceRecords, 1) + assert.Equal(t, "www", targeted.ResourceRecords[0].Name) + other, ok := fake.Zone("example.com", "b.ns.example.net") + require.True(t, ok) + assert.Empty(t, other.ResourceRecords) +} + func TestSearchEndpoint_Happy_FiltersByName(t *testing.T) { fake := fakeautodns.New(t) fake.SeedZone(models.Zone{Origin: "example.com", VirtualNameServer: "a.ns.example.net"}) diff --git a/fakeautodns/handlers.go b/fakeautodns/handlers.go index 6209d40..98cd5ff 100644 --- a/fakeautodns/handlers.go +++ b/fakeautodns/handlers.go @@ -223,6 +223,34 @@ func (s *Server) handleStreamRecords(w http.ResponseWriter, r *http.Request) { writeEnvelope(w, http.StatusOK, statusSuccess, "S0202", "records updated", nil) } +// handleUpdateRecords applies record adds and removals to exactly one +// zone, keyed by (origin, virtualNameServer) - the PATCH route the +// client's UpdateRecords uses. Unlike the origin-only _stream route, it +// targets one of several zones that may share an origin. +func (s *Server) handleUpdateRecords(w http.ResponseWriter, r *http.Request) { + key := zoneKey{origin: r.PathValue("origin"), virtualNameServer: r.PathValue("virtualNameServer")} + var request models.ZonePatchRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + writeBadRequest(w, "invalid request body") + return + } + + s.mu.Lock() + zone, ok := s.zones[key] + if ok { + zone.ResourceRecords = applyStream(zone.ResourceRecords, request.ResourceRecordsAdd, request.ResourceRecordsRem) + s.zones[key] = zone + } + s.mu.Unlock() + + if !ok { + writeZoneNotFound(w, key.origin) + return + } + + writeEnvelope(w, http.StatusOK, statusSuccess, "S0202", "records updated", []models.Zone{zone}) +} + // applyStream removes every record matching a rems entry on name, type, // and value, then appends the adds - mirroring the atomic semantics of // the real _stream endpoint. From 6b5bd3b6097b687f155833f3b6111d98cf49cb19 Mon Sep 17 00:00:00 2001 From: Peter Alserda Date: Wed, 22 Jul 2026 21:21:12 +0200 Subject: [PATCH 05/11] test: drive records through UpdateRecords, cover a shared-origin zone --- tests/harness_test.go | 6 ++++++ tests/record_stage_test.go | 25 +++++++++++++++---------- tests/record_test.go | 33 ++++++++++++++++++++++++++------- 3 files changed, 47 insertions(+), 17 deletions(-) diff --git a/tests/harness_test.go b/tests/harness_test.go index b265307..46b43b1 100644 --- a/tests/harness_test.go +++ b/tests/harness_test.go @@ -43,6 +43,12 @@ func seedZone(fake *fakeautodns.Server, origin string) { fake.SeedZone(models.Zone{Origin: origin, VirtualNameServer: testVirtualNameServer}) } +// seedZoneAtVNS stores a bare zone on the fake under a specific virtual +// name server, so a scenario can seed two zones that share an origin. +func seedZoneAtVNS(fake *fakeautodns.Server, origin, virtualNameServer string) { + fake.SeedZone(models.Zone{Origin: origin, VirtualNameServer: virtualNameServer}) +} + // seedZoneWithRecord stores a zone carrying a single record on the fake // under the shared virtual name server. func seedZoneWithRecord(fake *fakeautodns.Server, origin, name, recordType, value string) { diff --git a/tests/record_stage_test.go b/tests/record_stage_test.go index 43bec8a..af8b60c 100644 --- a/tests/record_stage_test.go +++ b/tests/record_stage_test.go @@ -12,8 +12,8 @@ import ( "github.com/alserda-nl/go-autodns/models" ) -// recordStage accumulates adds and rems through GIVEN steps, streams -// them in the WHEN step, and inspects the fake's post-stream state in +// recordStage accumulates adds and rems through GIVEN steps, updates +// them in the WHEN step, and inspects the fake's post-update state in // the THEN steps. type recordStage struct { t *testing.T @@ -54,6 +54,11 @@ func (s *recordStage) a_seeded_zone_with_a_record(origin, name, recordType, valu return s } +func (s *recordStage) a_seeded_zone_at_vns(origin, virtualNameServer string) *recordStage { + seedZoneAtVNS(s.fake, origin, virtualNameServer) + return s +} + func (s *recordStage) a_record_to_add(name, recordType, value string) *recordStage { s.adds = append(s.adds, models.ResourceRecord{ Name: name, @@ -76,8 +81,8 @@ func (s *recordStage) a_record_to_remove(name, recordType, value string) *record // --- WHEN --- -func (s *recordStage) the_records_are_streamed(origin string) *recordStage { - s.err = s.apiClient.StreamRecords(context.Background(), origin, s.adds, s.rems) +func (s *recordStage) the_records_are_updated(origin, virtualNameServer string) *recordStage { + s.err = s.apiClient.UpdateRecords(context.Background(), origin, virtualNameServer, s.adds, s.rems) return s } @@ -93,22 +98,22 @@ func (s *recordStage) the_error_is_zone_not_found() *recordStage { return s } -func (s *recordStage) the_zone_contains_record(origin, name string) *recordStage { - zone, ok := s.fake.Zone(origin, testVirtualNameServer) +func (s *recordStage) the_zone_contains_record(origin, virtualNameServer, name string) *recordStage { + zone, ok := s.fake.Zone(origin, virtualNameServer) s.require.True(ok) s.assert.Contains(recordNames(zone.ResourceRecords), name) return s } -func (s *recordStage) the_zone_does_not_contain_record(origin, name string) *recordStage { - zone, ok := s.fake.Zone(origin, testVirtualNameServer) +func (s *recordStage) the_zone_does_not_contain_record(origin, virtualNameServer, name string) *recordStage { + zone, ok := s.fake.Zone(origin, virtualNameServer) s.require.True(ok) s.assert.NotContains(recordNames(zone.ResourceRecords), name) return s } -func (s *recordStage) the_zone_record_count_is(origin string, n int) *recordStage { - zone, ok := s.fake.Zone(origin, testVirtualNameServer) +func (s *recordStage) the_zone_record_count_is(origin, virtualNameServer string, n int) *recordStage { + zone, ok := s.fake.Zone(origin, virtualNameServer) s.require.True(ok) s.assert.Len(zone.ResourceRecords, n) return s diff --git a/tests/record_test.go b/tests/record_test.go index 1d331ac..7a5537b 100644 --- a/tests/record_test.go +++ b/tests/record_test.go @@ -11,13 +11,13 @@ func TestRecord_StreamAddsAndRemovesAtomically(t *testing.T) { and().a_record_to_remove("old", "A", "192.0.2.99") when. - the_records_are_streamed("example.com") + the_records_are_updated("example.com", testVirtualNameServer) then. the_operation_succeeds(). - and().the_zone_contains_record("example.com", "www"). - and().the_zone_does_not_contain_record("example.com", "old"). - and().the_zone_record_count_is("example.com", 1) + and().the_zone_contains_record("example.com", testVirtualNameServer, "www"). + and().the_zone_does_not_contain_record("example.com", testVirtualNameServer, "old"). + and().the_zone_record_count_is("example.com", testVirtualNameServer, 1) } func TestRecord_StreamWithNoChangesLeavesRecordsUntouched(t *testing.T) { @@ -27,11 +27,11 @@ func TestRecord_StreamWithNoChangesLeavesRecordsUntouched(t *testing.T) { a_seeded_zone_with_a_record("example.com", "www", "A", "192.0.2.10") when. - the_records_are_streamed("example.com") + the_records_are_updated("example.com", testVirtualNameServer) then. the_operation_succeeds(). - and().the_zone_record_count_is("example.com", 1) + and().the_zone_record_count_is("example.com", testVirtualNameServer, 1) } func TestRecord_StreamToMissingZoneReturnsNotFound(t *testing.T) { @@ -42,8 +42,27 @@ func TestRecord_StreamToMissingZoneReturnsNotFound(t *testing.T) { and().a_record_to_add("www", "A", "192.0.2.10") when. - the_records_are_streamed("missing.example.com") + the_records_are_updated("missing.example.com", testVirtualNameServer) then. the_error_is_zone_not_found() } + +func TestRecord_UpdateTargetsOneZoneOfSharedOrigin(t *testing.T) { + given, when, then := newRecordStage(t) + + const otherVNS = "b.ns.example.net" + + given. + a_seeded_zone("example.com"). + and().a_seeded_zone_at_vns("example.com", otherVNS). + and().a_record_to_add("www", "A", "192.0.2.10") + + when. + the_records_are_updated("example.com", testVirtualNameServer) + + then. + the_operation_succeeds(). + and().the_zone_contains_record("example.com", testVirtualNameServer, "www"). + and().the_zone_record_count_is("example.com", otherVNS, 0) +} From 491f6abdb05faf5872fe0c2e9ab9d09e695163a5 Mon Sep 17 00:00:00 2001 From: Peter Alserda Date: Wed, 22 Jul 2026 21:28:34 +0200 Subject: [PATCH 06/11] feat!: remove origin-only StreamRecords in favor of UpdateRecords --- CHANGELOG.md | 4 +++ client/integration_test.go | 3 ++- client/record.go | 25 +++---------------- client/record_test.go | 40 +++--------------------------- fakeautodns/fakeautodns.go | 1 - fakeautodns/fakeautodns_test.go | 27 -------------------- fakeautodns/handlers.go | 44 +++++---------------------------- models/record.go | 20 +++------------ tests/record_test.go | 6 ++--- 9 files changed, 25 insertions(+), 145 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f61788f..a431963 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - `client` package: `UpdateRecords(ctx, origin, virtualNameServer, adds, rems)` applies record additions and removals to one specific zone via `PATCH /zone/{origin}/{virtualNameServer}`, so an origin with more than one zone can be managed. `models.ZonePatchRequest` is its request body. - `client` package: `ErrAmbiguousZone` sentinel, returned (via `errors.Is`) by `ResolveVirtualNameServer` when more than one zone shares an origin, so callers can distinguish an ambiguous origin from any other resolution failure. +### Removed + +- `client` package: `StreamRecords` and `models.RecordStreamRequest` (breaking). The origin-only `POST /zone/{origin}/_stream` endpoint cannot target one of several zones sharing an origin; `UpdateRecords` over `PATCH /zone/{origin}/{virtualNameServer}` replaces it. `fakeautodns` drops the `_stream` route accordingly. + ## [0.0.2] - 2026-07-20 ### Fixed diff --git a/client/integration_test.go b/client/integration_test.go index 67d93c3..2c6a50c 100644 --- a/client/integration_test.go +++ b/client/integration_test.go @@ -61,9 +61,10 @@ func TestIntegrationZoneLifecycle_Happy(t *testing.T) { t.Cleanup(func() { _ = apiClient.DeleteZone(ctx, origin, virtualNameServer) }) require.Equal(t, origin, created.Origin) - err = apiClient.StreamRecords( + err = apiClient.UpdateRecords( ctx, origin, + virtualNameServer, []models.ResourceRecord{ { Name: "www", diff --git a/client/record.go b/client/record.go index 9ecd52a..5f6ad34 100644 --- a/client/record.go +++ b/client/record.go @@ -4,33 +4,16 @@ import ( "context" "fmt" "net/http" - "net/url" "github.com/alserda-nl/go-autodns/models" ) -// StreamRecords atomically applies record additions and removals to a -// zone via the _stream endpoint - the AutoDNS API has no per-record -// endpoints. Unlike the other zone routes, the endpoint is keyed by -// origin alone (POST /zone/{origin}/_stream): the real API returns 404 -// for a path carrying a virtualNameServer segment. It returns an error -// matching ErrZoneNotFound when the zone does not exist. -func (c *Client) StreamRecords(ctx context.Context, origin string, adds, rems []models.ResourceRecord) error { - path := "/zone/" + url.PathEscape(origin) + "/_stream" - requestBody := models.RecordStreamRequest{Adds: adds, Rems: rems} - if _, err := c.doRequest(ctx, http.MethodPost, path, requestBody); err != nil { - return fmt.Errorf("streaming records: %w", err) - } - - return nil -} - // UpdateRecords atomically applies record additions and removals to one // zone via PATCH /zone/{origin}/{virtualNameServer}. AutoDNS has no -// per-record endpoints; this is the vns-targeted incremental route. The -// origin-only _stream endpoint cannot address one of several zones -// sharing an origin, so callers pass the specific virtualNameServer. It -// returns an error matching ErrZoneNotFound when the zone does not exist. +// per-record endpoints; this is the vns-targeted incremental route, +// letting callers address one of several zones sharing an origin by +// passing the specific virtualNameServer. It returns an error matching +// ErrZoneNotFound when the zone does not exist. func (c *Client) UpdateRecords(ctx context.Context, origin, virtualNameServer string, adds, rems []models.ResourceRecord) error { requestBody := models.ZonePatchRequest{ResourceRecordsAdd: adds, ResourceRecordsRem: rems} if _, err := c.doRequest(ctx, http.MethodPatch, zonePath(origin, virtualNameServer), requestBody); err != nil { diff --git a/client/record_test.go b/client/record_test.go index 55d8232..a27632b 100644 --- a/client/record_test.go +++ b/client/record_test.go @@ -13,45 +13,11 @@ import ( "github.com/alserda-nl/go-autodns/models" ) -// TestStreamRecords_Happy_PostsOriginOnlyPath pins the production route: -// the real API's stream endpoint is POST /zone/{origin}/_stream, without -// the virtualNameServer segment the other zone routes carry. Verified -// against api.autodns.com on 2026-07-20; the vns-carrying variant -// returns 404 there. -func TestStreamRecords_Happy_PostsOriginOnlyPath(t *testing.T) { - var gotMethod, gotPath string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotMethod, gotPath = r.Method, r.URL.Path - _, _ = w.Write([]byte(`{"status":{"type":"SUCCESS"}}`)) - })) - t.Cleanup(server.Close) - apiClient, err := client.New("user", "secret", "4", client.WithEndpoint(server.URL)) - require.NoError(t, err) - - err = apiClient.StreamRecords( - context.Background(), - "example.com", - []models.ResourceRecord{ - { - Name: "www", - Type: "A", - Value: "192.0.2.10", - TTL: 600, - }, - }, - nil, - ) - - require.NoError(t, err) - assert.Equal(t, http.MethodPost, gotMethod) - assert.Equal(t, "/zone/example.com/_stream", gotPath) -} - // TestUpdateRecords_Happy_PatchesVNSPath pins the production route: the // real API applies record adds and removals to one specific zone via -// PATCH /zone/{origin}/{virtualNameServer}. The origin-only _stream -// endpoint cannot target one of several zones sharing an origin, so the -// vns segment is load-bearing here. +// PATCH /zone/{origin}/{virtualNameServer} - the vns segment is +// load-bearing here, since it is what lets the call target one of +// several zones sharing an origin. func TestUpdateRecords_Happy_PatchesVNSPath(t *testing.T) { var gotMethod, gotPath string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/fakeautodns/fakeautodns.go b/fakeautodns/fakeautodns.go index 05d7368..91971a8 100644 --- a/fakeautodns/fakeautodns.go +++ b/fakeautodns/fakeautodns.go @@ -60,7 +60,6 @@ func New(t *testing.T) *Server { mux.HandleFunc("PUT /zone/{origin}/{virtualNameServer}", server.handleUpdateZone) mux.HandleFunc("DELETE /zone/{origin}/{virtualNameServer}", server.handleDeleteZone) mux.HandleFunc("PATCH /zone/{origin}/{virtualNameServer}", server.handleUpdateRecords) - mux.HandleFunc("POST /zone/{origin}/_stream", server.handleStreamRecords) server.Server = httptest.NewServer(server.intercept(mux)) t.Cleanup(server.Close) diff --git a/fakeautodns/fakeautodns_test.go b/fakeautodns/fakeautodns_test.go index 096fa4e..4dd668b 100644 --- a/fakeautodns/fakeautodns_test.go +++ b/fakeautodns/fakeautodns_test.go @@ -145,33 +145,6 @@ func TestReset_Happy(t *testing.T) { assert.Equal(t, http.StatusNotFound, statusCode) } -func TestStreamEndpoint_Happy_MutatesState(t *testing.T) { - fake := fakeautodns.New(t) - fake.SeedZone(models.Zone{ - Origin: "example.com", - VirtualNameServer: "a.ns.example.net", - ResourceRecords: []models.ResourceRecord{ - { - Name: "old", - Type: "A", - Value: "192.0.2.99", - TTL: 600, - }, - }, - }) - - statusCode, _ := doJSON(t, http.MethodPost, fake.URL+"/zone/example.com/_stream", `{ - "adds": [{"name": "www", "type": "A", "value": "192.0.2.10", "ttl": 600}], - "rems": [{"name": "old", "type": "A", "value": "192.0.2.99", "ttl": 600}] - }`) - - assert.Equal(t, http.StatusOK, statusCode) - zone, ok := fake.Zone("example.com", "a.ns.example.net") - require.True(t, ok) - require.Len(t, zone.ResourceRecords, 1) - assert.Equal(t, "www", zone.ResourceRecords[0].Name) -} - func TestServer_UpdateRecordsTargetsOneZoneOfSharedOrigin(t *testing.T) { fake := fakeautodns.New(t) fake.SeedZone(models.Zone{Origin: "example.com", VirtualNameServer: "a.ns.example.net"}) diff --git a/fakeautodns/handlers.go b/fakeautodns/handlers.go index 98cd5ff..279ca14 100644 --- a/fakeautodns/handlers.go +++ b/fakeautodns/handlers.go @@ -192,41 +192,9 @@ func (s *Server) handleDeleteZone(w http.ResponseWriter, r *http.Request) { writeEnvelope(w, http.StatusOK, statusSuccess, "S0203", "zone deleted", nil) } -// handleStreamRecords mutates zones by origin alone: the real _stream -// route carries no virtualNameServer segment, so every stored zone with -// the given origin gets the stream applied. -func (s *Server) handleStreamRecords(w http.ResponseWriter, r *http.Request) { - origin := r.PathValue("origin") - var request models.RecordStreamRequest - if err := json.NewDecoder(r.Body).Decode(&request); err != nil { - writeBadRequest(w, "invalid request body") - return - } - - s.mu.Lock() - found := false - for key, zone := range s.zones { - if key.origin != origin { - continue - } - zone.ResourceRecords = applyStream(zone.ResourceRecords, request.Adds, request.Rems) - s.zones[key] = zone - found = true - } - s.mu.Unlock() - - if !found { - writeZoneNotFound(w, origin) - return - } - - writeEnvelope(w, http.StatusOK, statusSuccess, "S0202", "records updated", nil) -} - // handleUpdateRecords applies record adds and removals to exactly one // zone, keyed by (origin, virtualNameServer) - the PATCH route the -// client's UpdateRecords uses. Unlike the origin-only _stream route, it -// targets one of several zones that may share an origin. +// client's UpdateRecords uses. func (s *Server) handleUpdateRecords(w http.ResponseWriter, r *http.Request) { key := zoneKey{origin: r.PathValue("origin"), virtualNameServer: r.PathValue("virtualNameServer")} var request models.ZonePatchRequest @@ -238,7 +206,7 @@ func (s *Server) handleUpdateRecords(w http.ResponseWriter, r *http.Request) { s.mu.Lock() zone, ok := s.zones[key] if ok { - zone.ResourceRecords = applyStream(zone.ResourceRecords, request.ResourceRecordsAdd, request.ResourceRecordsRem) + zone.ResourceRecords = applyRecordDelta(zone.ResourceRecords, request.ResourceRecordsAdd, request.ResourceRecordsRem) s.zones[key] = zone } s.mu.Unlock() @@ -251,10 +219,10 @@ func (s *Server) handleUpdateRecords(w http.ResponseWriter, r *http.Request) { writeEnvelope(w, http.StatusOK, statusSuccess, "S0202", "records updated", []models.Zone{zone}) } -// applyStream removes every record matching a rems entry on name, type, -// and value, then appends the adds - mirroring the atomic semantics of -// the real _stream endpoint. -func applyStream(records, adds, rems []models.ResourceRecord) []models.ResourceRecord { +// applyRecordDelta removes every record matching a rems entry on name, +// type, and value, then appends the adds - mirroring the atomic +// semantics of the real PATCH record update. +func applyRecordDelta(records, adds, rems []models.ResourceRecord) []models.ResourceRecord { kept := make([]models.ResourceRecord, 0, len(records)+len(adds)) for _, record := range records { if !containsRecord(rems, record) { diff --git a/models/record.go b/models/record.go index 757c0f1..e90f778 100644 --- a/models/record.go +++ b/models/record.go @@ -16,28 +16,14 @@ type ResourceRecord struct { Pref int32 `json:"pref,omitempty"` } -// RecordStreamRequest is the JSON body of the record stream endpoint -// (POST /zone/{origin}/_stream), which atomically applies record -// additions and removals to a zone. It is shared by the client's -// StreamRecords call and the fakeautodns stream handler. -type RecordStreamRequest struct { - // Adds are the records to add to the zone. - Adds []ResourceRecord `json:"adds"` - // Rems are the records to remove from the zone; a record matches on - // name, type, and value. - Rems []ResourceRecord `json:"rems"` -} - // ZonePatchRequest is the JSON body of the zone record-update endpoint // (PATCH /zone/{origin}/{virtualNameServer}), which atomically adds and -// removes records on one specific zone. It replaces RecordStreamRequest: -// the old _stream endpoint was keyed by origin alone and could not target -// one of several zones sharing an origin. +// removes records on one specific zone. type ZonePatchRequest struct { // ResourceRecordsAdd are the records to add to the zone. ResourceRecordsAdd []ResourceRecord `json:"resourceRecordsAdd,omitempty"` // ResourceRecordsRem are the records to remove. The API matches them - // on name, type, and value (as the old _stream endpoint did); this is - // confirmed on a throwaway zone before release, see the plan's Phase C. + // on name, type, and value; this is confirmed on a throwaway zone + // before release, see the plan's Phase C. ResourceRecordsRem []ResourceRecord `json:"resourceRecordsRem,omitempty"` } diff --git a/tests/record_test.go b/tests/record_test.go index 7a5537b..87458d4 100644 --- a/tests/record_test.go +++ b/tests/record_test.go @@ -2,7 +2,7 @@ package e2e import "testing" -func TestRecord_StreamAddsAndRemovesAtomically(t *testing.T) { +func TestRecord_UpdateAddsAndRemovesAtomically(t *testing.T) { given, when, then := newRecordStage(t) given. @@ -20,7 +20,7 @@ func TestRecord_StreamAddsAndRemovesAtomically(t *testing.T) { and().the_zone_record_count_is("example.com", testVirtualNameServer, 1) } -func TestRecord_StreamWithNoChangesLeavesRecordsUntouched(t *testing.T) { +func TestRecord_UpdateWithNoChangesLeavesRecordsUntouched(t *testing.T) { given, when, then := newRecordStage(t) given. @@ -34,7 +34,7 @@ func TestRecord_StreamWithNoChangesLeavesRecordsUntouched(t *testing.T) { and().the_zone_record_count_is("example.com", testVirtualNameServer, 1) } -func TestRecord_StreamToMissingZoneReturnsNotFound(t *testing.T) { +func TestRecord_UpdateToMissingZoneReturnsNotFound(t *testing.T) { given, when, then := newRecordStage(t) given. From 34d12b092be35739ce4ce0994c3626c5672827f7 Mon Sep 17 00:00:00 2001 From: Peter Alserda Date: Wed, 22 Jul 2026 21:43:07 +0200 Subject: [PATCH 07/11] chore: address Phase A review findings (README, doc comment, changelog, test) Fix a whole-branch review of Phase A (UpdateRecords/ErrAmbiguousZone): update the README's Usage example and Errors section to the new UpdateRecords surface, drop a public doc comment's reference to a never-committed planning doc, tighten two overlapping CHANGELOG bullets, and align a fakeautodns test's name and blank-line grouping with the file's conventions. --- CHANGELOG.md | 3 +-- README.md | 13 +++++++++---- fakeautodns/fakeautodns_test.go | 3 ++- models/record.go | 6 +++--- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a431963..15dbca5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added -- `client` package: `ResolveVirtualNameServer` resolves a zone's `virtualNameServer` from its origin via zone search, returning `ErrZoneNotFound` when no zone matches and a descriptive error when more than one zone shares the origin. Search results are filtered to an exact origin match before the count checks, so a server whose `name EQUAL` search matched loosely cannot make resolution wrongly ambiguous or resolve to the wrong zone. +- `client` package: `ResolveVirtualNameServer` resolves a zone's `virtualNameServer` from its origin via zone search, returning `ErrZoneNotFound` when no zone matches and `ErrAmbiguousZone` when more than one zone shares the origin. Search results are filtered to an exact origin match before the count checks, so a server whose `name EQUAL` search matched loosely cannot make resolution wrongly ambiguous or resolve to the wrong zone. - `client` package: `UpdateRecords(ctx, origin, virtualNameServer, adds, rems)` applies record additions and removals to one specific zone via `PATCH /zone/{origin}/{virtualNameServer}`, so an origin with more than one zone can be managed. `models.ZonePatchRequest` is its request body. -- `client` package: `ErrAmbiguousZone` sentinel, returned (via `errors.Is`) by `ResolveVirtualNameServer` when more than one zone shares an origin, so callers can distinguish an ambiguous origin from any other resolution failure. ### Removed diff --git a/README.md b/README.md index ebc29dc..83de170 100644 --- a/README.md +++ b/README.md @@ -49,12 +49,16 @@ func main() { } fmt.Println(zone.Origin, len(zone.ResourceRecords)) - // Atomically add and remove records. The AutoDNS API has no - // per-record endpoints; _stream is the record mutation primitive, - // keyed by origin alone. - err = apiClient.StreamRecords( + // Atomically add and remove records on one zone. The AutoDNS API has + // no per-record endpoints; UpdateRecords is the record mutation + // primitive, keyed by origin and virtualNameServer together, since + // an origin can have more than one zone. Pass a literal + // virtualNameServer if you already know it, or resolve it first + // with ResolveVirtualNameServer(ctx, origin). + err = apiClient.UpdateRecords( ctx, "example.com", + "a.ns.example.net", []models.ResourceRecord{ { Name: "www", @@ -90,6 +94,7 @@ Construction and lookup misses use sentinels, matched with `errors.Is`: - `client.ErrInvalidConfig` - `New` called with an empty username, password, or context. - `client.ErrZoneNotFound` - the API reported the requested zone does not exist. +- `client.ErrAmbiguousZone` - `ResolveVirtualNameServer` found more than one zone sharing the given origin. API-level failures use typed pointer errors, matched with `errors.As`: diff --git a/fakeautodns/fakeautodns_test.go b/fakeautodns/fakeautodns_test.go index 4dd668b..8fb77d6 100644 --- a/fakeautodns/fakeautodns_test.go +++ b/fakeautodns/fakeautodns_test.go @@ -145,7 +145,7 @@ func TestReset_Happy(t *testing.T) { assert.Equal(t, http.StatusNotFound, statusCode) } -func TestServer_UpdateRecordsTargetsOneZoneOfSharedOrigin(t *testing.T) { +func TestUpdateRecordsEndpoint_Happy_TargetsOneZoneOfSharedOrigin(t *testing.T) { fake := fakeautodns.New(t) fake.SeedZone(models.Zone{Origin: "example.com", VirtualNameServer: "a.ns.example.net"}) fake.SeedZone(models.Zone{Origin: "example.com", VirtualNameServer: "b.ns.example.net"}) @@ -159,6 +159,7 @@ func TestServer_UpdateRecordsTargetsOneZoneOfSharedOrigin(t *testing.T) { require.True(t, ok) require.Len(t, targeted.ResourceRecords, 1) assert.Equal(t, "www", targeted.ResourceRecords[0].Name) + other, ok := fake.Zone("example.com", "b.ns.example.net") require.True(t, ok) assert.Empty(t, other.ResourceRecords) diff --git a/models/record.go b/models/record.go index e90f778..d4e2526 100644 --- a/models/record.go +++ b/models/record.go @@ -22,8 +22,8 @@ type ResourceRecord struct { type ZonePatchRequest struct { // ResourceRecordsAdd are the records to add to the zone. ResourceRecordsAdd []ResourceRecord `json:"resourceRecordsAdd,omitempty"` - // ResourceRecordsRem are the records to remove. The API matches them - // on name, type, and value; this is confirmed on a throwaway zone - // before release, see the plan's Phase C. + // ResourceRecordsRem are the records to remove. Matching semantics are + // inherited from the old _stream endpoint (name, type, and value); they + // are not yet independently verified against the live API for this route. ResourceRecordsRem []ResourceRecord `json:"resourceRecordsRem,omitempty"` } From 2ac80c343b7790a3076b29e71865af664ef9a400 Mon Sep 17 00:00:00 2001 From: Peter Alserda Date: Thu, 23 Jul 2026 12:31:10 +0200 Subject: [PATCH 08/11] test: seed both shared-origin zones explicitly for clarity --- tests/record_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/record_test.go b/tests/record_test.go index 87458d4..2e9ed22 100644 --- a/tests/record_test.go +++ b/tests/record_test.go @@ -54,7 +54,7 @@ func TestRecord_UpdateTargetsOneZoneOfSharedOrigin(t *testing.T) { const otherVNS = "b.ns.example.net" given. - a_seeded_zone("example.com"). + a_seeded_zone_at_vns("example.com", testVirtualNameServer). and().a_seeded_zone_at_vns("example.com", otherVNS). and().a_record_to_add("www", "A", "192.0.2.10") From b2b5bb237371a142c78504f12a7bf7429c4249dd Mon Sep 17 00:00:00 2001 From: Peter Alserda Date: Thu, 23 Jul 2026 12:36:37 +0200 Subject: [PATCH 09/11] test: keep the virtual name server out of single-zone e2e scenarios Single-zone scenarios used a_seeded_zone (vns implicit) but then named testVirtualNameServer in the WHEN and THEN steps, so the vns appeared from nowhere when reading top to bottom. Default helpers now hide the vns entirely; only the shared-origin scenario names it, on both sides. --- tests/record_stage_test.go | 32 +++++++++++++++++++++++++++----- tests/record_test.go | 23 ++++++++++++----------- 2 files changed, 39 insertions(+), 16 deletions(-) diff --git a/tests/record_stage_test.go b/tests/record_stage_test.go index af8b60c..c77d30d 100644 --- a/tests/record_stage_test.go +++ b/tests/record_stage_test.go @@ -81,7 +81,18 @@ func (s *recordStage) a_record_to_remove(name, recordType, value string) *record // --- WHEN --- -func (s *recordStage) the_records_are_updated(origin, virtualNameServer string) *recordStage { +// the_records_are_updated_in updates records in origin's single zone (on +// the shared testVirtualNameServer). Single-zone scenarios use this and +// never mention a virtual name server; the shared-origin scenario uses +// the_records_are_updated_at_vns instead. +func (s *recordStage) the_records_are_updated_in(origin string) *recordStage { + return s.the_records_are_updated_at_vns(origin, testVirtualNameServer) +} + +// the_records_are_updated_at_vns updates records in one specific zone, +// naming the virtual name server, for the shared-origin scenarios where +// which zone is targeted is the point of the test. +func (s *recordStage) the_records_are_updated_at_vns(origin, virtualNameServer string) *recordStage { s.err = s.apiClient.UpdateRecords(context.Background(), origin, virtualNameServer, s.adds, s.rems) return s } @@ -98,21 +109,32 @@ func (s *recordStage) the_error_is_zone_not_found() *recordStage { return s } -func (s *recordStage) the_zone_contains_record(origin, virtualNameServer, name string) *recordStage { +func (s *recordStage) the_zone_contains_record(origin, name string) *recordStage { + return s.the_zone_at_vns_contains_record(origin, testVirtualNameServer, name) +} + +func (s *recordStage) the_zone_at_vns_contains_record(origin, virtualNameServer, name string) *recordStage { zone, ok := s.fake.Zone(origin, virtualNameServer) s.require.True(ok) s.assert.Contains(recordNames(zone.ResourceRecords), name) return s } -func (s *recordStage) the_zone_does_not_contain_record(origin, virtualNameServer, name string) *recordStage { - zone, ok := s.fake.Zone(origin, virtualNameServer) +func (s *recordStage) the_zone_does_not_contain_record(origin, name string) *recordStage { + zone, ok := s.fake.Zone(origin, testVirtualNameServer) s.require.True(ok) s.assert.NotContains(recordNames(zone.ResourceRecords), name) return s } -func (s *recordStage) the_zone_record_count_is(origin, virtualNameServer string, n int) *recordStage { +func (s *recordStage) the_zone_record_count_is(origin string, n int) *recordStage { + zone, ok := s.fake.Zone(origin, testVirtualNameServer) + s.require.True(ok) + s.assert.Len(zone.ResourceRecords, n) + return s +} + +func (s *recordStage) the_zone_at_vns_record_count_is(origin, virtualNameServer string, n int) *recordStage { zone, ok := s.fake.Zone(origin, virtualNameServer) s.require.True(ok) s.assert.Len(zone.ResourceRecords, n) diff --git a/tests/record_test.go b/tests/record_test.go index 2e9ed22..3edaca1 100644 --- a/tests/record_test.go +++ b/tests/record_test.go @@ -11,13 +11,13 @@ func TestRecord_UpdateAddsAndRemovesAtomically(t *testing.T) { and().a_record_to_remove("old", "A", "192.0.2.99") when. - the_records_are_updated("example.com", testVirtualNameServer) + the_records_are_updated_in("example.com") then. the_operation_succeeds(). - and().the_zone_contains_record("example.com", testVirtualNameServer, "www"). - and().the_zone_does_not_contain_record("example.com", testVirtualNameServer, "old"). - and().the_zone_record_count_is("example.com", testVirtualNameServer, 1) + and().the_zone_contains_record("example.com", "www"). + and().the_zone_does_not_contain_record("example.com", "old"). + and().the_zone_record_count_is("example.com", 1) } func TestRecord_UpdateWithNoChangesLeavesRecordsUntouched(t *testing.T) { @@ -27,11 +27,11 @@ func TestRecord_UpdateWithNoChangesLeavesRecordsUntouched(t *testing.T) { a_seeded_zone_with_a_record("example.com", "www", "A", "192.0.2.10") when. - the_records_are_updated("example.com", testVirtualNameServer) + the_records_are_updated_in("example.com") then. the_operation_succeeds(). - and().the_zone_record_count_is("example.com", testVirtualNameServer, 1) + and().the_zone_record_count_is("example.com", 1) } func TestRecord_UpdateToMissingZoneReturnsNotFound(t *testing.T) { @@ -42,7 +42,7 @@ func TestRecord_UpdateToMissingZoneReturnsNotFound(t *testing.T) { and().a_record_to_add("www", "A", "192.0.2.10") when. - the_records_are_updated("missing.example.com", testVirtualNameServer) + the_records_are_updated_in("missing.example.com") then. the_error_is_zone_not_found() @@ -51,18 +51,19 @@ func TestRecord_UpdateToMissingZoneReturnsNotFound(t *testing.T) { func TestRecord_UpdateTargetsOneZoneOfSharedOrigin(t *testing.T) { given, when, then := newRecordStage(t) + const targetVNS = "a.ns.example.net" const otherVNS = "b.ns.example.net" given. - a_seeded_zone_at_vns("example.com", testVirtualNameServer). + a_seeded_zone_at_vns("example.com", targetVNS). and().a_seeded_zone_at_vns("example.com", otherVNS). and().a_record_to_add("www", "A", "192.0.2.10") when. - the_records_are_updated("example.com", testVirtualNameServer) + the_records_are_updated_at_vns("example.com", targetVNS) then. the_operation_succeeds(). - and().the_zone_contains_record("example.com", testVirtualNameServer, "www"). - and().the_zone_record_count_is("example.com", otherVNS, 0) + and().the_zone_at_vns_contains_record("example.com", targetVNS, "www"). + and().the_zone_at_vns_record_count_is("example.com", otherVNS, 0) } From b981b64d3eed178b2c9858b74b325d928e1d90db Mon Sep 17 00:00:00 2001 From: Peter Alserda Date: Thu, 23 Jul 2026 12:59:37 +0200 Subject: [PATCH 10/11] test: seed the resolve-vns scenario at an explicit virtual name server TestZone_ResolveVirtualNameServerReturnsTheSeededVNS seeded via a_seeded_zone (vns implicit) then asserted testVirtualNameServer out of nowhere. Seeding explicitly at testVirtualNameServer makes the expected resolved value visible in the GIVEN. --- tests/zone_stage_test.go | 5 +++++ tests/zone_test.go | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/zone_stage_test.go b/tests/zone_stage_test.go index e15bde3..281f193 100644 --- a/tests/zone_stage_test.go +++ b/tests/zone_stage_test.go @@ -50,6 +50,11 @@ func (s *zoneStage) a_seeded_zone(origin string) *zoneStage { return s } +func (s *zoneStage) a_seeded_zone_at_vns(origin, virtualNameServer string) *zoneStage { + seedZoneAtVNS(s.fake, origin, virtualNameServer) + return s +} + func (s *zoneStage) a_seeded_zone_with_a_record(origin, name, recordType, value string) *zoneStage { seedZoneWithRecord(s.fake, origin, name, recordType, value) return s diff --git a/tests/zone_test.go b/tests/zone_test.go index 93d8851..c72d248 100644 --- a/tests/zone_test.go +++ b/tests/zone_test.go @@ -167,7 +167,7 @@ func TestZone_ResolveVirtualNameServerReturnsTheSeededVNS(t *testing.T) { given, when, then := newZoneStage(t) given. - a_seeded_zone("example.com") + a_seeded_zone_at_vns("example.com", testVirtualNameServer) when. the_vns_is_resolved("example.com") From 2419995a4a164fa8cff61d28acf356f47bccdc74 Mon Sep 17 00:00:00 2001 From: Peter Alserda Date: Thu, 23 Jul 2026 13:18:26 +0200 Subject: [PATCH 11/11] docs: record that PATCH remove-matching is verified against the live API --- models/record.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/models/record.go b/models/record.go index d4e2526..0d1b74f 100644 --- a/models/record.go +++ b/models/record.go @@ -22,8 +22,8 @@ type ResourceRecord struct { type ZonePatchRequest struct { // ResourceRecordsAdd are the records to add to the zone. ResourceRecordsAdd []ResourceRecord `json:"resourceRecordsAdd,omitempty"` - // ResourceRecordsRem are the records to remove. Matching semantics are - // inherited from the old _stream endpoint (name, type, and value); they - // are not yet independently verified against the live API for this route. + // ResourceRecordsRem are the records to remove. The API matches them on + // name, type, and value (verified against the live API), so two records + // sharing those three fields cannot be removed independently. ResourceRecordsRem []ResourceRecord `json:"resourceRecordsRem,omitempty"` }