diff --git a/CHANGELOG.md b/CHANGELOG.md index 48880af..15dbca5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,23 @@ 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 `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. + +### 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 - `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/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/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/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 cd8f089..5f6ad34 100644 --- a/client/record.go +++ b/client/record.go @@ -4,22 +4,20 @@ 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) +// 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, +// 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 { + return fmt.Errorf("updating records: %w", err) } return nil diff --git a/client/record_test.go b/client/record_test.go index fb590a3..a27632b 100644 --- a/client/record_test.go +++ b/client/record_test.go @@ -13,12 +13,12 @@ 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) { +// 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 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) { gotMethod, gotPath = r.Method, r.URL.Path @@ -28,9 +28,10 @@ func TestStreamRecords_Happy_PostsOriginOnlyPath(t *testing.T) { apiClient, err := client.New("user", "secret", "4", client.WithEndpoint(server.URL)) require.NoError(t, err) - err = apiClient.StreamRecords( + err = apiClient.UpdateRecords( context.Background(), "example.com", + "a.ns.example.net", []models.ResourceRecord{ { Name: "www", @@ -43,6 +44,6 @@ func TestStreamRecords_Happy_PostsOriginOnlyPath(t *testing.T) { ) require.NoError(t, err) - assert.Equal(t, http.MethodPost, gotMethod) - assert.Equal(t, "/zone/example.com/_stream", gotPath) + assert.Equal(t, http.MethodPatch, gotMethod) + assert.Equal(t, "/zone/example.com/a.ns.example.net", gotPath) } diff --git a/client/zone.go b/client/zone.go index 002c217..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" ) @@ -102,6 +104,54 @@ 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 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 +// 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 { + 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 +} + // 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..50d19a1 --- /dev/null +++ b/client/zone_test.go @@ -0,0 +1,83 @@ +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 + expectedSentinel error // nil means no sentinel to check + }{ + { + 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: "", + expectedSentinel: nil, + }, + { + 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", + 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: "b.ns.example.net", + expectedSentinel: client.ErrAmbiguousZone, + }, + } + + 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) + if tc.expectedSentinel != nil { + require.ErrorIs(t, err, tc.expectedSentinel) + } + return + } + + require.NoError(t, err) + assert.Equal(t, tc.expectedVNS, actualVNS) + }) + } +} diff --git a/fakeautodns/fakeautodns.go b/fakeautodns/fakeautodns.go index bb0cddc..91971a8 100644 --- a/fakeautodns/fakeautodns.go +++ b/fakeautodns/fakeautodns.go @@ -59,7 +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("POST /zone/{origin}/_stream", server.handleStreamRecords) + mux.HandleFunc("PATCH /zone/{origin}/{virtualNameServer}", server.handleUpdateRecords) server.Server = httptest.NewServer(server.intercept(mux)) t.Cleanup(server.Close) diff --git a/fakeautodns/fakeautodns_test.go b/fakeautodns/fakeautodns_test.go index 8c12233..8fb77d6 100644 --- a/fakeautodns/fakeautodns_test.go +++ b/fakeautodns/fakeautodns_test.go @@ -145,31 +145,24 @@ func TestReset_Happy(t *testing.T) { assert.Equal(t, http.StatusNotFound, statusCode) } -func TestStreamEndpoint_Happy_MutatesState(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", - ResourceRecords: []models.ResourceRecord{ - { - Name: "old", - Type: "A", - Value: "192.0.2.99", - TTL: 600, - }, - }, - }) + 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.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}] + 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) - zone, ok := fake.Zone("example.com", "a.ns.example.net") + 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) - require.Len(t, zone.ResourceRecords, 1) - assert.Equal(t, "www", zone.ResourceRecords[0].Name) + assert.Empty(t, other.ResourceRecords) } func TestSearchEndpoint_Happy_FiltersByName(t *testing.T) { diff --git a/fakeautodns/handlers.go b/fakeautodns/handlers.go index 6209d40..279ca14 100644 --- a/fakeautodns/handlers.go +++ b/fakeautodns/handlers.go @@ -192,41 +192,37 @@ 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 +// handleUpdateRecords applies record adds and removals to exactly one +// zone, keyed by (origin, virtualNameServer) - the PATCH route the +// 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 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) + zone, ok := s.zones[key] + if ok { + zone.ResourceRecords = applyRecordDelta(zone.ResourceRecords, request.ResourceRecordsAdd, request.ResourceRecordsRem) s.zones[key] = zone - found = true } s.mu.Unlock() - if !found { - writeZoneNotFound(w, origin) + if !ok { + writeZoneNotFound(w, key.origin) return } - writeEnvelope(w, http.StatusOK, statusSuccess, "S0202", "records updated", nil) + 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 fce3b31..0d1b74f 100644 --- a/models/record.go +++ b/models/record.go @@ -16,14 +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. +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 (verified against the live API), so two records + // sharing those three fields cannot be removed independently. + ResourceRecordsRem []ResourceRecord `json:"resourceRecordsRem,omitempty"` } 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..c77d30d 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,19 @@ 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) +// 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 } @@ -94,7 +110,11 @@ func (s *recordStage) the_error_is_zone_not_found() *recordStage { } func (s *recordStage) the_zone_contains_record(origin, name string) *recordStage { - zone, ok := s.fake.Zone(origin, testVirtualNameServer) + 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 @@ -113,3 +133,10 @@ func (s *recordStage) the_zone_record_count_is(origin string, n int) *recordStag 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) + return s +} diff --git a/tests/record_test.go b/tests/record_test.go index 1d331ac..3edaca1 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. @@ -11,7 +11,7 @@ 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_in("example.com") then. the_operation_succeeds(). @@ -20,21 +20,21 @@ func TestRecord_StreamAddsAndRemovesAtomically(t *testing.T) { and().the_zone_record_count_is("example.com", 1) } -func TestRecord_StreamWithNoChangesLeavesRecordsUntouched(t *testing.T) { +func TestRecord_UpdateWithNoChangesLeavesRecordsUntouched(t *testing.T) { given, when, then := newRecordStage(t) given. 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_in("example.com") then. the_operation_succeeds(). and().the_zone_record_count_is("example.com", 1) } -func TestRecord_StreamToMissingZoneReturnsNotFound(t *testing.T) { +func TestRecord_UpdateToMissingZoneReturnsNotFound(t *testing.T) { given, when, then := newRecordStage(t) given. @@ -42,8 +42,28 @@ 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_in("missing.example.com") then. the_error_is_zone_not_found() } + +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", 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_at_vns("example.com", targetVNS) + + then. + the_operation_succeeds(). + and().the_zone_at_vns_contains_record("example.com", targetVNS, "www"). + and().the_zone_at_vns_record_count_is("example.com", otherVNS, 0) +} diff --git a/tests/zone_stage_test.go b/tests/zone_stage_test.go index 8bfb9a3..281f193 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 } @@ -48,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 @@ -60,6 +67,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 +164,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 +225,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..c72d248 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_at_vns("example.com", testVirtualNameServer) + + 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) +}