From 5182b0eaf6c6163f71282ebc84e32c7c342103b1 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Tue, 26 May 2026 20:48:41 -0400 Subject: [PATCH 1/4] feat(client): add ListDomains for account-level enumeration Adds Client.ListDomains calling GET /api/domains; returns the full []Domain list (ID + Name) for the authenticated account. Distinct from GetDomain (which targets /api/domains//dns for per-zone records); ListDomains is the account-level inverse-key needed by the upstream IaCProviderEnumerator.EnumerateAll("infra.dns") path. Loud-on-failure semantics: - HTTP non-2xx surfaces as a Go error (rather than empty slice). - Body-level {succeeded: false} also surfaces as an error so callers don't act on stale state. Part of docs/plans/2026-05-26-dns-provider-contract.md PR 4 (Task 10). --- pkg/hoverclient/client.go | 42 ++++++++++++++++++++++ pkg/hoverclient/client_test.go | 66 ++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+) diff --git a/pkg/hoverclient/client.go b/pkg/hoverclient/client.go index 2533505..2f9be20 100644 --- a/pkg/hoverclient/client.go +++ b/pkg/hoverclient/client.go @@ -360,6 +360,48 @@ func (c *Client) putNameserversLocked(ctx context.Context, domainName string, ns return nil } +// ListDomains fetches every domain in the authenticated account via the +// account-level GET /api/domains endpoint. The returned slice is the +// inverse-key of the SetNameservers / GetDomainDelegation / GetDomain +// surface (which all operate on a single named zone) — callers iterate +// the list to drive cross-zone operations like +// IaCProviderEnumerator.EnumerateAll("infra.dns"). +// +// CSRF is not required for GET requests under Hover's API; ensureLogin +// is still called so the session cookie is fresh. +func (c *Client) ListDomains(ctx context.Context) ([]Domain, error) { + if err := c.ensureLogin(ctx); err != nil { + return nil, fmt.Errorf("hover: ListDomains: login: %w", err) + } + endpoint := hoverHost + "/api/domains" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("hover: ListDomains: build request: %w", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", c.UserAgent) + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("hover: ListDomains: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return nil, fmt.Errorf("hover: ListDomains: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + var body struct { + Succeeded bool `json:"succeeded"` + Domains []Domain `json:"domains"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return nil, fmt.Errorf("hover: ListDomains: decode: %w", err) + } + if !body.Succeeded { + return nil, fmt.Errorf("hover: ListDomains: API returned succeeded=false") + } + return body.Domains, nil +} + // GetDomain returns the full Domain struct (including the // hover-assigned ID) for the named zone. The ID is required when // creating new records via CreateRecord; the human-readable name is diff --git a/pkg/hoverclient/client_test.go b/pkg/hoverclient/client_test.go index 7f36127..71d7e1d 100644 --- a/pkg/hoverclient/client_test.go +++ b/pkg/hoverclient/client_test.go @@ -598,6 +598,72 @@ func TestClient_ListRecords_DomainNotFound(t *testing.T) { } } +// ── ListDomains coverage ─────────────────────────────────────────────────── + +// TestClient_ListDomains verifies the GET /api/domains path returns the +// deserialized []Domain. Hover's account-level endpoint is the prerequisite +// for the upstream IaCProviderEnumerator.EnumerateAll path (cross-repo +// cascade docs/plans/2026-05-26-dns-provider-contract.md). +func TestClient_ListDomains(t *testing.T) { + respBody := `{ + "succeeded": true, + "domains": [ + {"id": "dom1", "domain_name": "alpha.test"}, + {"id": "dom2", "domain_name": "beta.test"} + ] + }` + c, srv := newRecordStub(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/api/domains" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + _, _ = io.WriteString(w, respBody) + }) + defer srv.Close() + domains, err := c.ListDomains(context.Background()) + if err != nil { + t.Fatalf("ListDomains: %v", err) + } + if len(domains) != 2 { + t.Fatalf("want 2 domains; got %d", len(domains)) + } + if domains[0].Name != "alpha.test" || domains[0].ID != "dom1" { + t.Errorf("domains[0] = %+v", domains[0]) + } + if domains[1].Name != "beta.test" || domains[1].ID != "dom2" { + t.Errorf("domains[1] = %+v", domains[1]) + } +} + +// TestClient_ListDomains_HTTPError surfaces non-2xx as a Go error rather +// than returning an empty slice (which would silently look like an empty +// account). +func TestClient_ListDomains_HTTPError(t *testing.T) { + c, srv := newRecordStub(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = io.WriteString(w, "boom") + }) + defer srv.Close() + _, err := c.ListDomains(context.Background()) + if err == nil { + t.Fatalf("want HTTP-error; got nil") + } +} + +// TestClient_ListDomains_APIFalseSucceeded guards against the body-level +// {"succeeded": false} signal (Hover's API contract): even with HTTP 200, +// false succeeded must surface as an error so callers don't act on stale +// state. +func TestClient_ListDomains_APIFalseSucceeded(t *testing.T) { + c, srv := newRecordStub(t, func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, `{"succeeded": false, "domains": []}`) + }) + defer srv.Close() + _, err := c.ListDomains(context.Background()) + if err == nil { + t.Fatalf("want succeeded=false error; got nil") + } +} + func TestExtractCSRFMeta_AttributeOrders(t *testing.T) { cases := []struct{ name, html, want string }{ {"name-first double quotes", ``, "abc"}, From 29413f86270feb0a153cc4c4479851ce74e088d9 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Tue, 26 May 2026 20:50:10 -0400 Subject: [PATCH 2/4] test(provider): add failing EnumerateAll infra.dns coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the hoverDomainLister interface (single-method shape: ListDomains) plus a domains field on HoverProvider so EnumerateAll can list the account's zones via either the real *hoverclient.Client or a slice-backed fake (fakeHoverClient) in tests. Initialize() now wires domains = client. The EnumerateAll method is intentionally not yet defined — tests fail at build time with 'no field or method EnumerateAll', driving the next commit's implementation. Part of docs/plans/2026-05-26-dns-provider-contract.md PR 4 (Task 12). --- internal/provider.go | 13 ++++++ internal/provider_test.go | 88 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/internal/provider.go b/internal/provider.go index 5c5afd5..0dc4972 100644 --- a/internal/provider.go +++ b/internal/provider.go @@ -19,6 +19,14 @@ import ( // Version is set at build time via -ldflags. var Version = "0.0.0" +// hoverDomainLister is the minimal account-level surface EnumerateAll needs. +// *hoverclient.Client satisfies this interface via the ListDomains method +// added in pkg/hoverclient; the test fake (fakeHoverClient) satisfies it +// the same way without spinning up the real login flow. +type hoverDomainLister interface { + ListDomains(ctx context.Context) ([]hoverclient.Domain, error) +} + // HoverProvider implements interfaces.IaCProvider for Hover. // Supports two resource types: // - infra.dns — DNS records within Hover's nameservers. @@ -26,6 +34,10 @@ var Version = "0.0.0" type HoverProvider struct { client *hoverclient.Client drivers map[string]interfaces.ResourceDriver + // domains is the injected account-level domain lister used by EnumerateAll. + // Defaults to client (which now satisfies hoverDomainLister); tests + // override with a fakeHoverClient. + domains hoverDomainLister } var _ interfaces.IaCProvider = (*HoverProvider)(nil) @@ -78,6 +90,7 @@ func (p *HoverProvider) Initialize(ctx context.Context, config map[string]any) e } p.client = c + p.domains = c p.drivers = map[string]interfaces.ResourceDriver{ "infra.dns": drivers.NewDNSDriver(c), "infra.dns_delegation": drivers.NewDelegationDriver(c), diff --git a/internal/provider_test.go b/internal/provider_test.go index 9a46416..ea2ad10 100644 --- a/internal/provider_test.go +++ b/internal/provider_test.go @@ -7,6 +7,7 @@ import ( "sync/atomic" "testing" + "github.com/GoCodeAlone/workflow-plugin-hover/pkg/hoverclient" "github.com/GoCodeAlone/workflow/interfaces" ) @@ -111,3 +112,90 @@ func (d *noopCreateDriver) Scale(context.Context, interfaces.ResourceRef, int) ( func (d *noopCreateDriver) SensitiveKeys() []string { return nil } + +// ── EnumerateAll(infra.dns) coverage ──────────────────────────────────────── + +// fakeHoverClient is a slice-backed hoverDomainLister used to drive +// EnumerateAll tests without touching the real hoverclient.Client (which +// requires a live login flow). +type fakeHoverClient struct { + domains []hoverclient.Domain + err error + calls int +} + +func (f *fakeHoverClient) ListDomains(_ context.Context) ([]hoverclient.Domain, error) { + f.calls++ + if f.err != nil { + return nil, f.err + } + return f.domains, nil +} + +func TestHoverProvider_EnumerateAll_DNS(t *testing.T) { + stub := &fakeHoverClient{ + domains: []hoverclient.Domain{ + // hoverclient.Domain.Name is the Go field (json tag is "domain_name"). + {ID: "dom-1", Name: "alpha.test"}, + {ID: "dom-2", Name: "beta.test"}, + }, + } + p := &HoverProvider{domains: stub} + out, err := p.EnumerateAll(context.Background(), "infra.dns") + if err != nil { + t.Fatalf("EnumerateAll: %v", err) + } + if len(out) != 2 { + t.Fatalf("want 2; got %d", len(out)) + } + if out[0].ProviderID != "alpha.test" { + t.Errorf("providerID[0] = %q; want alpha.test", out[0].ProviderID) + } + if out[0].Type != "infra.dns" { + t.Errorf("type[0] = %q; want infra.dns", out[0].Type) + } + if out[0].Outputs["zone"] != "alpha.test" { + t.Errorf("zone[0] = %v", out[0].Outputs["zone"]) + } + if out[0].Outputs["domain_id"] != "dom-1" { + t.Errorf("domain_id[0] = %v", out[0].Outputs["domain_id"]) + } + if stub.calls != 1 { + t.Errorf("ListDomains called %d times; want 1", stub.calls) + } +} + +func TestHoverProvider_EnumerateAll_DNS_uninitialized(t *testing.T) { + p := &HoverProvider{} + _, err := p.EnumerateAll(context.Background(), "infra.dns") + if err == nil { + t.Fatalf("want uninitialized error; got nil") + } +} + +func TestHoverProvider_EnumerateAll_DNS_unsupportedType(t *testing.T) { + p := &HoverProvider{domains: &fakeHoverClient{}} + _, err := p.EnumerateAll(context.Background(), "infra.compute") + if err == nil { + t.Fatalf("want unsupported-type error; got nil") + } +} + +// TestHoverProvider_EnumerateAll_DNS_skipsBlankName ensures zones with empty +// Name strings are dropped rather than emitted with empty ProviderID. +func TestHoverProvider_EnumerateAll_DNS_skipsBlankName(t *testing.T) { + stub := &fakeHoverClient{ + domains: []hoverclient.Domain{ + {ID: "dom-empty", Name: ""}, + {ID: "dom-real", Name: "real.test"}, + }, + } + p := &HoverProvider{domains: stub} + out, err := p.EnumerateAll(context.Background(), "infra.dns") + if err != nil { + t.Fatalf("EnumerateAll: %v", err) + } + if len(out) != 1 || out[0].ProviderID != "real.test" { + t.Fatalf("want 1 entry with ProviderID=real.test; got %+v", out) + } +} From ed62a30bc40b5ee4c63e5b9dd8fab40757f2e1f1 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Tue, 26 May 2026 20:51:38 -0400 Subject: [PATCH 3/4] feat(provider): implement EnumerateAll for infra.dns via pkg/hoverclient ListDomains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the existing IaCProviderEnumerator service body on hoverIaCServer (was an Unimplemented stub) and adds the matching *HoverProvider.EnumerateAll Go method. Production uses *hoverclient.Client.ListDomains; tests inject a slice-backed hoverDomainLister (fakeHoverClient). Per-zone Outputs carry zone + domain_id so the downstream IaCProvider.Import path can adopt the zone without re-querying the account list. Domains with empty Name are dropped rather than emitted with empty ProviderID — guards against malformed upstream rows. Adds gRPC-level coverage on hoverIaCServer.EnumerateAll exercising the outputs_json marshalling round-trip. Part of docs/plans/2026-05-26-dns-provider-contract.md PR 4 (Task 12). --- internal/iacserver.go | 34 ++++++++++++++++++++++++++++++ internal/iacserver_test.go | 43 ++++++++++++++++++++++++++++++++++++++ internal/provider.go | 38 +++++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+) diff --git a/internal/iacserver.go b/internal/iacserver.go index 0eb8e2b..e5e3b7a 100644 --- a/internal/iacserver.go +++ b/internal/iacserver.go @@ -175,6 +175,40 @@ func (s *hoverIaCServer) FinalizeApply(_ context.Context, _ *pb.FinalizeApplyReq return &pb.FinalizeApplyResponse{}, nil } +// EnumerateAll satisfies pb.IaCProviderEnumeratorServer.EnumerateAll. Mirrors +// the Go-level interfaces.EnumeratorAll on *HoverProvider so the wfctl +// `infra import-all` path can list every Hover-registered zone in one +// account-level round-trip. +func (s *hoverIaCServer) EnumerateAll(ctx context.Context, req *pb.EnumerateAllRequest) (*pb.EnumerateAllResponse, error) { + outs, err := s.provider.EnumerateAll(ctx, req.GetResourceType()) + if err != nil { + return nil, err + } + pbOuts := make([]*pb.ResourceOutput, 0, len(outs)) + for _, o := range outs { + if o == nil { + continue + } + outputsJSON, err := json.Marshal(o.Outputs) + if err != nil { + return nil, fmt.Errorf("hover iacserver: encode EnumerateAll outputs: %w", err) + } + sensitive := make(map[string]bool, len(o.Sensitive)) + for k, v := range o.Sensitive { + sensitive[k] = v + } + pbOuts = append(pbOuts, &pb.ResourceOutput{ + Name: o.Name, + Type: o.Type, + ProviderId: o.ProviderID, + OutputsJson: outputsJSON, + Sensitive: sensitive, + Status: o.Status, + }) + } + return &pb.EnumerateAllResponse{Outputs: pbOuts}, nil +} + // ── Drift detection ─────────────────────────────────────────────────────────── func (s *hoverIaCServer) DetectDrift(ctx context.Context, req *pb.DetectDriftRequest) (*pb.DetectDriftResponse, error) { diff --git a/internal/iacserver_test.go b/internal/iacserver_test.go index 79751c0..b9da581 100644 --- a/internal/iacserver_test.go +++ b/internal/iacserver_test.go @@ -2,8 +2,10 @@ package internal import ( "context" + "encoding/json" "testing" + "github.com/GoCodeAlone/workflow-plugin-hover/pkg/hoverclient" "github.com/GoCodeAlone/workflow/interfaces" pb "github.com/GoCodeAlone/workflow/plugin/external/proto" ) @@ -261,3 +263,44 @@ func (d *iacServerFakeDriver) Scale(_ context.Context, ref interfaces.ResourceRe } func (d *iacServerFakeDriver) SensitiveKeys() []string { return nil } + +// ── EnumerateAll gRPC coverage ───────────────────────────────────────────── + +// TestHoverIaCServer_EnumerateAll_DNS exercises the typed gRPC surface +// (hoverIaCServer.EnumerateAll). The SDK auto-registers this service at +// plugin startup because hoverIaCServer embeds the Unimplemented*Enumerator +// stub and overrides EnumerateAll. This test confirms the proto<->Go +// marshalling round-trips zone + domain_id outputs. +func TestHoverIaCServer_EnumerateAll_DNS(t *testing.T) { + srv := &hoverIaCServer{ + provider: &HoverProvider{ + domains: &fakeHoverClient{ + domains: []hoverclient.Domain{ + {ID: "dom-1", Name: "alpha.test"}, + {ID: "dom-2", Name: "beta.test"}, + }, + }, + }, + } + resp, err := srv.EnumerateAll(context.Background(), &pb.EnumerateAllRequest{ResourceType: "infra.dns"}) + if err != nil { + t.Fatalf("EnumerateAll: %v", err) + } + if len(resp.GetOutputs()) != 2 { + t.Fatalf("want 2 outputs; got %d", len(resp.GetOutputs())) + } + first := resp.GetOutputs()[0] + if first.GetProviderId() != "alpha.test" { + t.Errorf("providerID = %q; want alpha.test", first.GetProviderId()) + } + if first.GetType() != "infra.dns" { + t.Errorf("type = %q; want infra.dns", first.GetType()) + } + var outputs map[string]any + if err := json.Unmarshal(first.GetOutputsJson(), &outputs); err != nil { + t.Fatalf("unmarshal outputs: %v", err) + } + if outputs["zone"] != "alpha.test" || outputs["domain_id"] != "dom-1" { + t.Errorf("outputs = %#v", outputs) + } +} diff --git a/internal/provider.go b/internal/provider.go index 0dc4972..83bdff9 100644 --- a/internal/provider.go +++ b/internal/provider.go @@ -293,6 +293,44 @@ func (p *HoverProvider) SupportedCanonicalKeys() []string { // Close is a no-op; the HTTP client has no persistent connections to tear down. func (p *HoverProvider) Close() error { return nil } +// EnumerateAll implements interfaces.EnumeratorAll for resource type +// "infra.dns". Walks the account's zones via the injected hoverDomainLister +// (production wraps *hoverclient.Client.ListDomains — added in pkg/hoverclient +// for the cross-repo cascade). Each *ResourceOutput carries the zone name + +// hover-assigned domain_id so the downstream IaCProvider.Import path can +// adopt the zone without re-querying the account list. +// +// Domains with empty Name are dropped rather than emitted with empty +// ProviderID — guards against bogus state-store entries if the Hover +// account-page ever returns a malformed row. +func (p *HoverProvider) EnumerateAll(ctx context.Context, resourceType string) ([]*interfaces.ResourceOutput, error) { + if p.domains == nil { + return nil, fmt.Errorf("hover: EnumerateAll called on provider that is not initialized — call Initialize first") + } + if resourceType != "infra.dns" { + return nil, fmt.Errorf("hover: EnumerateAll: resource type %q not supported", resourceType) + } + domains, err := p.domains.ListDomains(ctx) + if err != nil { + return nil, fmt.Errorf("hover: EnumerateAll infra.dns: %w", err) + } + out := make([]*interfaces.ResourceOutput, 0, len(domains)) + for _, d := range domains { + if d.Name == "" { + continue + } + out = append(out, &interfaces.ResourceOutput{ + ProviderID: d.Name, + Type: "infra.dns", + Outputs: map[string]any{ + "zone": d.Name, + "domain_id": d.ID, + }, + }) + } + return out, nil +} + // isNotFound recognises a "resource doesn't exist upstream" error. // The driver wraps these with interfaces.ErrResourceNotFound, so // prefer the sentinel check via errors.Is. The string fallback From 9fb3ad3dddafe7b993115ab630e63e6eeb2b0c38 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Tue, 26 May 2026 20:52:15 -0400 Subject: [PATCH 4/4] test(provider): env-gated live EnumerateAll infra.dns test Adds an INFRA_DNS_ENUMERATE_LIVE=1 build-tagged smoke test that exercises the real *hoverclient.Client.ListDomains against a live Hover account. Live runs require HOVER_USERNAME + HOVER_PASSWORD; HOVER_TOTP_SECRET is optional and only needed when the test account has MFA enabled. Part of docs/plans/2026-05-26-dns-provider-contract.md PR 4 (Task 13). --- internal/iacserver_live_test.go | 81 +++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 internal/iacserver_live_test.go diff --git a/internal/iacserver_live_test.go b/internal/iacserver_live_test.go new file mode 100644 index 0000000..939594a --- /dev/null +++ b/internal/iacserver_live_test.go @@ -0,0 +1,81 @@ +//go:build live_dns + +// Env-gated live integration coverage for EnumerateAll("infra.dns"). +// +// Run with: +// +// INFRA_DNS_ENUMERATE_LIVE=1 \ +// HOVER_USERNAME=$USER \ +// HOVER_PASSWORD=$PASS \ +// GOWORK=off go test -tags live_dns \ +// -run TestHoverProvider_EnumerateAll_DNS_live ./internal/... +// +// HOVER_TOTP_SECRET is optional; supply it when the test account has MFA +// enabled. Per docs/plans/2026-05-26-dns-provider-contract.md PR 4 (Task 13). +package internal + +import ( + "context" + "os" + "testing" + + "github.com/GoCodeAlone/workflow-plugin-hover/pkg/hoverclient" +) + +// newLiveHoverProvider builds a HoverProvider whose `domains` field is +// wired to the production *hoverclient.Client. Credentials come from +// HOVER_USERNAME + HOVER_PASSWORD (+ optional HOVER_TOTP_SECRET); the +// helper aborts the test (t.Fatal) when required env is missing so the +// live-only run is loud rather than silent. +func newLiveHoverProvider(t *testing.T) *HoverProvider { + t.Helper() + user := os.Getenv("HOVER_USERNAME") + pass := os.Getenv("HOVER_PASSWORD") + if user == "" || pass == "" { + t.Fatal("HOVER_USERNAME + HOVER_PASSWORD must be set for live EnumerateAll test") + } + var totpSecret hoverclient.TOTPSecret + if totpRaw := os.Getenv("HOVER_TOTP_SECRET"); totpRaw != "" { + ts, err := hoverclient.ParseBase32(totpRaw) + if err != nil { + t.Fatalf("invalid HOVER_TOTP_SECRET: %v", err) + } + totpSecret = ts + } + creds := hoverclient.Credentials{ + Username: user, + Password: pass, + TOTPSecret: totpSecret, + } + c, err := hoverclient.NewClient(creds, nil) + if err != nil { + t.Fatalf("hoverclient.NewClient: %v", err) + } + return &HoverProvider{client: c, domains: c} +} + +func TestHoverProvider_EnumerateAll_DNS_live(t *testing.T) { + if os.Getenv("INFRA_DNS_ENUMERATE_LIVE") != "1" { + t.Skip("set INFRA_DNS_ENUMERATE_LIVE=1 + HOVER_USERNAME + HOVER_PASSWORD to run") + } + p := newLiveHoverProvider(t) + out, err := p.EnumerateAll(context.Background(), "infra.dns") + if err != nil { + t.Fatalf("live EnumerateAll: %v", err) + } + if len(out) == 0 { + t.Skip("account has zero domains; cannot validate") + } + for _, o := range out { + if o.ProviderID == "" { + t.Errorf("empty ProviderID for %+v", o.Outputs) + } + if o.Type != "infra.dns" { + t.Errorf("wrong Type %q", o.Type) + } + if _, ok := o.Outputs["zone"]; !ok { + t.Errorf("missing zone output: %+v", o.Outputs) + } + } + t.Logf("enumerated %d hover domains", len(out)) +}