diff --git a/CHANGELOG.md b/CHANGELOG.md index 11d4a6f..6398a59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `internal/domain`, `internal/subdomain`, and `internal/dns` read + modules with the matching CLI subcommand trees: `kasapi-cli domains + list` and `domains get ` (`get_domains`, the latter passing a + `domain_name` filter and unwrapping the single-entry result), + `kasapi-cli subdomains list` (`get_subdomains`), `kasapi-cli tlds + list` (`get_topleveldomains`), and `kasapi-cli dns list --domain + [--nameserver ]` (`get_dns_settings`). Domain types `Domain`, + `SSL`, `TLD`, `Subdomain`, and DNS `Record` decode the KAS Map/Array + payloads into typed Go values; the SSL cert/key/CSR PEM bodies are + carried through but summarised as `` in the + `--output=table` view of `domains get` so the key/value layout stays + readable. Mapping tests run against the shipped `testdata/domain/`, + `testdata/subdomain/`, and `testdata/dns/` fixtures. Closes #8. + - `internal/usage` package and `kasapi-cli usage` subcommand tree covering the three KAS read endpoints around webspace and traffic counters: `usage space` (`get_space`) lists per-account webspace diff --git a/cmd/kasapi-cli/main.go b/cmd/kasapi-cli/main.go index 801b630..6a092c8 100644 --- a/cmd/kasapi-cli/main.go +++ b/cmd/kasapi-cli/main.go @@ -17,6 +17,10 @@ func main() { root.AddCommand( cli.NewAccountCmd(opts), cli.NewServerCmd(opts), + cli.NewDomainsCmd(opts), + cli.NewSubdomainsCmd(opts), + cli.NewTLDsCmd(opts), + cli.NewDNSCmd(opts), cli.NewUsageCmd(opts), cli.NewConfigCmd(opts), ) diff --git a/internal/cli/dns.go b/internal/cli/dns.go new file mode 100644 index 0000000..198b370 --- /dev/null +++ b/internal/cli/dns.go @@ -0,0 +1,50 @@ +package cli + +import ( + "errors" + + "github.com/spf13/cobra" + + "github.com/chmmou/kasapi-cli/internal/dns" +) + +// NewDNSCmd returns the "kasapi-cli dns" subcommand tree: +// list --domain [--nameserver ] (get_dns_settings). +func NewDNSCmd(opts *RootOptions) *cobra.Command { + cmd := &cobra.Command{ + Use: "dns", + Short: "Inspect DNS records for a zone", + } + cmd.AddCommand(newDNSListCmd(opts)) + return cmd +} + +func newDNSListCmd(opts *RootOptions) *cobra.Command { + var domainName, nameserver string + cmd := &cobra.Command{ + Use: "list", + Short: "List DNS records for a zone (get_dns_settings)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if domainName == "" { + return UserError(errors.New("required flag --domain not provided"), "--domain") + } + api, err := BuildAPIClient(opts) + if err != nil { + return err + } + list, err := dns.NewClient(api).Settings(cmd.Context(), domainName, nameserver) + if err != nil { + return APIError(err, "get_dns_settings") + } + if err := Render(cmd.OutOrStdout(), opts.Output, list); err != nil { + return UserError(err, "render") + } + return nil + }, + } + cmd.Flags().StringVar(&domainName, "domain", "", "zone host (required, e.g. example.com)") + cmd.Flags().StringVar(&nameserver, "nameserver", "", + "authoritative nameserver to query; empty uses the KAS default") + return cmd +} diff --git a/internal/cli/domains.go b/internal/cli/domains.go new file mode 100644 index 0000000..d3e7928 --- /dev/null +++ b/internal/cli/domains.go @@ -0,0 +1,65 @@ +package cli + +import ( + "github.com/spf13/cobra" + + "github.com/chmmou/kasapi-cli/internal/domain" +) + +// NewDomainsCmd returns the "kasapi-cli domains" subcommand tree: +// list (get_domains), get (get_domains with domain_name). +func NewDomainsCmd(opts *RootOptions) *cobra.Command { + cmd := &cobra.Command{ + Use: "domains", + Short: "Inspect domains owned by the authenticated account", + } + cmd.AddCommand( + newDomainsListCmd(opts), + newDomainsGetCmd(opts), + ) + return cmd +} + +func newDomainsListCmd(opts *RootOptions) *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List all domains (get_domains)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + api, err := BuildAPIClient(opts) + if err != nil { + return err + } + list, err := domain.NewClient(api).List(cmd.Context()) + if err != nil { + return APIError(err, "get_domains") + } + if err := Render(cmd.OutOrStdout(), opts.Output, list); err != nil { + return UserError(err, "render") + } + return nil + }, + } +} + +func newDomainsGetCmd(opts *RootOptions) *cobra.Command { + return &cobra.Command{ + Use: "get ", + Short: "Show details for a single domain (get_domains with domain_name)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + api, err := BuildAPIClient(opts) + if err != nil { + return err + } + d, err := domain.NewClient(api).Get(cmd.Context(), args[0]) + if err != nil { + return APIError(err, "get_domains") + } + if err := Render(cmd.OutOrStdout(), opts.Output, d); err != nil { + return UserError(err, "render") + } + return nil + }, + } +} diff --git a/internal/cli/subdomains.go b/internal/cli/subdomains.go new file mode 100644 index 0000000..2a97b4d --- /dev/null +++ b/internal/cli/subdomains.go @@ -0,0 +1,40 @@ +package cli + +import ( + "github.com/spf13/cobra" + + "github.com/chmmou/kasapi-cli/internal/subdomain" +) + +// NewSubdomainsCmd returns the "kasapi-cli subdomains" subcommand +// tree: list (get_subdomains). +func NewSubdomainsCmd(opts *RootOptions) *cobra.Command { + cmd := &cobra.Command{ + Use: "subdomains", + Short: "Inspect subdomains owned by the authenticated account", + } + cmd.AddCommand(newSubdomainsListCmd(opts)) + return cmd +} + +func newSubdomainsListCmd(opts *RootOptions) *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List all subdomains (get_subdomains)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + api, err := BuildAPIClient(opts) + if err != nil { + return err + } + list, err := subdomain.NewClient(api).List(cmd.Context()) + if err != nil { + return APIError(err, "get_subdomains") + } + if err := Render(cmd.OutOrStdout(), opts.Output, list); err != nil { + return UserError(err, "render") + } + return nil + }, + } +} diff --git a/internal/cli/tlds.go b/internal/cli/tlds.go new file mode 100644 index 0000000..e18623f --- /dev/null +++ b/internal/cli/tlds.go @@ -0,0 +1,40 @@ +package cli + +import ( + "github.com/spf13/cobra" + + "github.com/chmmou/kasapi-cli/internal/domain" +) + +// NewTLDsCmd returns the "kasapi-cli tlds" subcommand tree: +// list (get_topleveldomains). +func NewTLDsCmd(opts *RootOptions) *cobra.Command { + cmd := &cobra.Command{ + Use: "tlds", + Short: "Inspect the catalog of registrable top-level domains", + } + cmd.AddCommand(newTLDsListCmd(opts)) + return cmd +} + +func newTLDsListCmd(opts *RootOptions) *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List all registrable TLDs (get_topleveldomains)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + api, err := BuildAPIClient(opts) + if err != nil { + return err + } + list, err := domain.NewClient(api).TopLevelDomains(cmd.Context()) + if err != nil { + return APIError(err, "get_topleveldomains") + } + if err := Render(cmd.OutOrStdout(), opts.Output, list); err != nil { + return UserError(err, "render") + } + return nil + }, + } +} diff --git a/internal/dns/dns.go b/internal/dns/dns.go new file mode 100644 index 0000000..8af6ac7 --- /dev/null +++ b/internal/dns/dns.go @@ -0,0 +1,149 @@ +package dns + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/chmmou/kasapi-cli/internal/soap" +) + +// Caller is the subset of *api.Client this package depends on. The +// indirection keeps tests free of network setup: a fake Caller can +// return a *soap.Response decoded from a fixture. +type Caller interface { + Call(ctx context.Context, action string, params map[string]any) (*soap.Response, error) +} + +// Record is one DNS resource record returned by get_dns_settings. +// record_id is xsd:string in the wire format and we keep it as a +// string here — the value identifies the record on update / delete +// calls but is otherwise opaque. +type Record struct { + Zone string `json:"record_zone" yaml:"record_zone"` + Name string `json:"record_name" yaml:"record_name"` + Type string `json:"record_type" yaml:"record_type"` + Data string `json:"record_data" yaml:"record_data"` + Aux int `json:"record_aux" yaml:"record_aux"` + ID string `json:"record_id" yaml:"record_id"` + Changeable string `json:"record_changeable" yaml:"record_changeable"` + Deleteable string `json:"record_deleteable" yaml:"record_deleteable"` +} + +// RecordList is the typed payload of get_dns_settings; satisfies +// cli.Tabular. +type RecordList []Record + +// Client groups the read endpoints scoped to DNS settings. +type Client struct { + API Caller +} + +// NewClient returns a Client backed by the given Caller. +func NewClient(c Caller) *Client { return &Client{API: c} } + +// Settings calls get_dns_settings for the given zone host and decodes +// the response into a RecordList. zoneHost is required (the zone the +// records belong to, e.g. "example.com"). nameserver is optional and +// pinpoints which authoritative NS to query when the zone is served +// by more than one — leave it empty to use the KAS default. +func (c *Client) Settings(ctx context.Context, zoneHost, nameserver string) (RecordList, error) { + if zoneHost == "" { + return nil, fmt.Errorf("dns: zone_host is required") + } + params := map[string]any{"zone_host": zoneHost} + if nameserver != "" { + params["nameserver"] = nameserver + } + resp, err := c.API.Call(ctx, "get_dns_settings", params) + if err != nil { + return nil, err + } + list, err := DecodeRecords(resp.Body.ReturnInfo) + if err != nil { + return nil, fmt.Errorf("dns: get_dns_settings: %w", err) + } + return list, nil +} + +// DecodeRecords maps the ReturnInfo of a get_dns_settings response +// (an Array of Maps) into the typed RecordList. +func DecodeRecords(returnInfo soap.Value) (RecordList, error) { + if returnInfo.Kind != soap.KindArray { + return nil, fmt.Errorf("dns: expected ReturnInfo array, got kind %d", returnInfo.Kind) + } + out := make(RecordList, 0, len(returnInfo.Array)) + for i, item := range returnInfo.Array { + if item.Kind != soap.KindMap { + return nil, fmt.Errorf("dns: ReturnInfo[%d] is not a Map", i) + } + out = append(out, Record{ + Zone: getString(item, "record_zone"), + Name: getString(item, "record_name"), + Type: getString(item, "record_type"), + Data: getString(item, "record_data"), + Aux: getInt(item, "record_aux"), + ID: getString(item, "record_id"), + Changeable: getString(item, "record_changeable"), + Deleteable: getString(item, "record_deleteable"), + }) + } + return out, nil +} + +func getString(m soap.Value, key string) string { + v, ok := m.Get(key) + if !ok { + return "" + } + return v.AsString() +} + +func getInt(m soap.Value, key string) int { + v, ok := m.Get(key) + if !ok { + return 0 + } + switch v.Kind { + case soap.KindInt: + return int(v.Int) + case soap.KindFloat: + return int(v.Float) + case soap.KindString: + s := strings.TrimSpace(v.String) + if s == "" { + return 0 + } + n, err := strconv.Atoi(s) + if err != nil { + return 0 + } + return n + } + return 0 +} + +// TableHeaders returns the columns used by --output=table for +// RecordList. +func (RecordList) TableHeaders() []string { + return []string{"ID", "ZONE", "NAME", "TYPE", "AUX", "DATA", "CHG", "DEL"} +} + +// TableRows emits one row per Record entry. +func (l RecordList) TableRows() [][]string { + rows := make([][]string, 0, len(l)) + for _, r := range l { + rows = append(rows, []string{ + r.ID, + r.Zone, + r.Name, + r.Type, + strconv.Itoa(r.Aux), + r.Data, + r.Changeable, + r.Deleteable, + }) + } + return rows +} diff --git a/internal/dns/dns_test.go b/internal/dns/dns_test.go new file mode 100644 index 0000000..d86f2d4 --- /dev/null +++ b/internal/dns/dns_test.go @@ -0,0 +1,168 @@ +package dns_test + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/chmmou/kasapi-cli/internal/dns" + "github.com/chmmou/kasapi-cli/internal/soap" +) + +func repoRoot(t *testing.T) string { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + dir := filepath.Dir(file) + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatalf("repo root not found from %q", file) + } + dir = parent + } +} + +func decodeFixture(t *testing.T, name string) *soap.Response { + t.Helper() + path := filepath.Join(repoRoot(t), "testdata", "dns", name) + f, err := os.Open(path) + if err != nil { + t.Fatalf("open %s: %v", name, err) + } + defer func() { _ = f.Close() }() + resp, err := soap.Decode(f) + if err != nil { + t.Fatalf("decode %s: %v", name, err) + } + return resp +} + +type fakeCaller struct { + resp *soap.Response + err error + + gotAction string + gotParams map[string]any +} + +func (f *fakeCaller) Call(_ context.Context, action string, params map[string]any) (*soap.Response, error) { + f.gotAction = action + f.gotParams = params + return f.resp, f.err +} + +func TestDecodeRecords(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_dns_settings_response_success.xml") + got, err := dns.DecodeRecords(resp.Body.ReturnInfo) + if err != nil { + t.Fatalf("DecodeRecords: %v", err) + } + if len(got) != 6 { + t.Fatalf("len = %d, want 6", len(got)) + } + + // First record: A record at apex pointing at 127.0.0.1. + a := got[0] + if a.Type != "A" || a.Data != "127.0.0.1" || a.Name != "" { + t.Errorf("first record = %+v", a) + } + if a.Aux != 0 || a.ID != "22675113" { + t.Errorf("first record aux/id = %d/%q", a.Aux, a.ID) + } + if a.Changeable != "Y" || a.Deleteable != "Y" { + t.Errorf("first record flags = %q/%q", a.Changeable, a.Deleteable) + } + + // Second record: MX with aux=10. + mx := got[1] + if mx.Type != "MX" || mx.Aux != 10 { + t.Errorf("MX record aux = %d, want 10", mx.Aux) + } + + // DKIM TXT entry has Deleteable=N (the only non-Y flag in the + // fixture); make sure that round-trips. + dkim := got[5] + if dkim.Name != "abc012345678901._domainkey" { + t.Errorf("dkim record name = %q", dkim.Name) + } + if dkim.Deleteable != "N" { + t.Errorf("dkim Deleteable = %q, want N", dkim.Deleteable) + } +} + +func TestClientSettings(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_dns_settings_response_success.xml") + fc := &fakeCaller{resp: resp} + list, err := dns.NewClient(fc).Settings(context.Background(), "example.com", "") + if err != nil { + t.Fatalf("Settings: %v", err) + } + if fc.gotAction != "get_dns_settings" { + t.Errorf("action = %q, want get_dns_settings", fc.gotAction) + } + if zh, _ := fc.gotParams["zone_host"].(string); zh != "example.com" { + t.Errorf("params[zone_host] = %v, want example.com", fc.gotParams["zone_host"]) + } + if _, ok := fc.gotParams["nameserver"]; ok { + t.Errorf("params[nameserver] set but nameserver was empty: %v", fc.gotParams) + } + if len(list) != 6 { + t.Errorf("len = %d, want 6", len(list)) + } +} + +func TestClientSettingsWithNameserver(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_dns_settings_response_success.xml") + fc := &fakeCaller{resp: resp} + if _, err := dns.NewClient(fc).Settings(context.Background(), "example.com", "ns.example.com"); err != nil { + t.Fatalf("Settings: %v", err) + } + if ns, _ := fc.gotParams["nameserver"].(string); ns != "ns.example.com" { + t.Errorf("params[nameserver] = %v, want ns.example.com", fc.gotParams["nameserver"]) + } +} + +func TestClientSettingsRequiresZoneHost(t *testing.T) { + t.Parallel() + c := dns.NewClient(&fakeCaller{}) + if _, err := c.Settings(context.Background(), "", ""); err == nil { + t.Errorf("Settings(\"\") err = nil, want validation error") + } +} + +func TestClientPropagatesError(t *testing.T) { + t.Parallel() + want := errors.New("boom") + c := dns.NewClient(&fakeCaller{err: want}) + if _, err := c.Settings(context.Background(), "example.com", ""); !errors.Is(err, want) { + t.Errorf("Settings err = %v, want %v wrapped", err, want) + } +} + +func TestRecordListTabular(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_dns_settings_response_success.xml") + list, _ := dns.DecodeRecords(resp.Body.ReturnInfo) + rows := list.TableRows() + if len(rows) != 6 { + t.Fatalf("rows = %d, want 6", len(rows)) + } + if rows[0][0] != "22675113" || rows[0][3] != "A" { + t.Errorf("rows[0] = %v", rows[0]) + } + if rows[1][3] != "MX" || rows[1][4] != "10" { + t.Errorf("rows[1] aux column = %v", rows[1]) + } +} diff --git a/internal/domain/domain.go b/internal/domain/domain.go new file mode 100644 index 0000000..145ee37 --- /dev/null +++ b/internal/domain/domain.go @@ -0,0 +1,335 @@ +package domain + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/chmmou/kasapi-cli/internal/soap" +) + +// Caller is the subset of *api.Client this package depends on. The +// indirection keeps tests free of network setup: a fake Caller can +// return a *soap.Response decoded from a fixture. +type Caller interface { + Call(ctx context.Context, action string, params map[string]any) (*soap.Response, error) +} + +// Domain is one entry of get_domains. The KAS list view exposes the +// account/server placement and a flattened SSL summary; the singular +// view (get_domains with a domain_name filter) omits the placement +// fields but adds dummy_host / dkim_selector and the full SSL cert +// PEM bodies. We model both shapes with one struct and rely on +// `omitempty` so neither view emits empty placeholder fields. +type Domain struct { + Name string `json:"domain_name" yaml:"domain_name"` + TLD string `json:"domain_tld,omitempty" yaml:"domain_tld,omitempty"` + RedirectStatus int `json:"domain_redirect_status" yaml:"domain_redirect_status"` + Path string `json:"domain_path" yaml:"domain_path"` + Account string `json:"domain_account,omitempty" yaml:"domain_account,omitempty"` + Server string `json:"domain_server,omitempty" yaml:"domain_server,omitempty"` + + DummyHost string `json:"dummy_host,omitempty" yaml:"dummy_host,omitempty"` + DKIMSelector string `json:"dkim_selector,omitempty" yaml:"dkim_selector,omitempty"` + FPSEActive string `json:"fpse_active" yaml:"fpse_active"` + PHPVersion string `json:"php_version" yaml:"php_version"` + PHPDeprecated string `json:"php_deprecated" yaml:"php_deprecated"` + IsActive string `json:"is_active" yaml:"is_active"` + InProgress string `json:"in_progress" yaml:"in_progress"` + + StatisticVersion int `json:"statistic_version" yaml:"statistic_version"` + StatisticLanguage string `json:"statistic_language" yaml:"statistic_language"` + + SSL SSL `json:"ssl" yaml:"ssl"` +} + +// SSL groups the ssl_* keys returned for a domain. The cert/key/CSR +// bodies are only present in the singular get_domains response +// (with domain_name filter); the list view leaves them as xsi:nil +// which decodes to an empty string here. +type SSL struct { + Proxy string `json:"proxy" yaml:"proxy"` + CertificateIP string `json:"certificate_ip" yaml:"certificate_ip"` + SNI string `json:"sni" yaml:"sni"` + SNIIsActive string `json:"sni_is_active" yaml:"sni_is_active"` + SNICSR string `json:"sni_csr,omitempty" yaml:"sni_csr,omitempty"` + SNIKey string `json:"sni_key,omitempty" yaml:"sni_key,omitempty"` + SNICRT string `json:"sni_crt,omitempty" yaml:"sni_crt,omitempty"` + SNIBundle string `json:"sni_bundle,omitempty" yaml:"sni_bundle,omitempty"` + SNIChainfile string `json:"sni_chainfile,omitempty" yaml:"sni_chainfile,omitempty"` + SNIType string `json:"sni_type,omitempty" yaml:"sni_type,omitempty"` + SNIForceHTTPS string `json:"sni_force_https,omitempty" yaml:"sni_force_https,omitempty"` + SNIHSTSMaxAge string `json:"sni_hsts_max_age,omitempty" yaml:"sni_hsts_max_age,omitempty"` +} + +// DomainList is the typed payload of get_domains; satisfies cli.Tabular. +type DomainList []Domain + +// TLD is one entry of get_topleveldomains. +type TLD struct { + Name string `json:"tld_name" yaml:"tld_name"` + MinLen int `json:"tld_minlen" yaml:"tld_minlen"` + MaxLen int `json:"tld_maxlen" yaml:"tld_maxlen"` +} + +// TLDList is the typed payload of get_topleveldomains; satisfies +// cli.Tabular. +type TLDList []TLD + +// Client groups the read endpoints scoped to domains: +// get_domains (list and singular) plus get_topleveldomains. +type Client struct { + API Caller +} + +// NewClient returns a Client backed by the given Caller. +func NewClient(c Caller) *Client { return &Client{API: c} } + +// List calls get_domains without parameters and decodes the response +// into a DomainList covering every domain visible to the login. +func (c *Client) List(ctx context.Context) (DomainList, error) { + resp, err := c.API.Call(ctx, "get_domains", nil) + if err != nil { + return nil, err + } + list, err := DecodeDomains(resp.Body.ReturnInfo) + if err != nil { + return nil, fmt.Errorf("domain: get_domains: %w", err) + } + return list, nil +} + +// Get calls get_domains with a domain_name filter and returns the +// single matching Domain. The KAS API still wraps the result in an +// array; we unwrap it here so callers do not have to. An empty array +// surfaces as a not-found error. +func (c *Client) Get(ctx context.Context, name string) (Domain, error) { + if name == "" { + return Domain{}, fmt.Errorf("domain: name is required") + } + resp, err := c.API.Call(ctx, "get_domains", map[string]any{"domain_name": name}) + if err != nil { + return Domain{}, err + } + list, err := DecodeDomains(resp.Body.ReturnInfo) + if err != nil { + return Domain{}, fmt.Errorf("domain: get_domains: %w", err) + } + if len(list) == 0 { + return Domain{}, fmt.Errorf("domain: %q not found", name) + } + return list[0], nil +} + +// TopLevelDomains calls get_topleveldomains and decodes the response. +func (c *Client) TopLevelDomains(ctx context.Context) (TLDList, error) { + resp, err := c.API.Call(ctx, "get_topleveldomains", nil) + if err != nil { + return nil, err + } + list, err := DecodeTLDs(resp.Body.ReturnInfo) + if err != nil { + return nil, fmt.Errorf("domain: get_topleveldomains: %w", err) + } + return list, nil +} + +// DecodeDomains maps the ReturnInfo of a get_domains response (an +// Array of Maps) into the typed DomainList. +func DecodeDomains(returnInfo soap.Value) (DomainList, error) { + if returnInfo.Kind != soap.KindArray { + return nil, fmt.Errorf("domain: expected ReturnInfo array, got kind %d", returnInfo.Kind) + } + out := make(DomainList, 0, len(returnInfo.Array)) + for i, item := range returnInfo.Array { + if item.Kind != soap.KindMap { + return nil, fmt.Errorf("domain: ReturnInfo[%d] is not a Map", i) + } + out = append(out, decodeDomain(item)) + } + return out, nil +} + +// DecodeTLDs maps the ReturnInfo of a get_topleveldomains response +// (an Array of Maps) into the typed TLDList. +func DecodeTLDs(returnInfo soap.Value) (TLDList, error) { + if returnInfo.Kind != soap.KindArray { + return nil, fmt.Errorf("domain: expected ReturnInfo array, got kind %d", returnInfo.Kind) + } + out := make(TLDList, 0, len(returnInfo.Array)) + for i, item := range returnInfo.Array { + if item.Kind != soap.KindMap { + return nil, fmt.Errorf("domain: ReturnInfo[%d] is not a Map", i) + } + out = append(out, TLD{ + Name: getString(item, "tld_name"), + MinLen: getInt(item, "tld_minlen"), + MaxLen: getInt(item, "tld_maxlen"), + }) + } + return out, nil +} + +func decodeDomain(m soap.Value) Domain { + return Domain{ + Name: getString(m, "domain_name"), + TLD: getString(m, "domain_tld"), + RedirectStatus: getInt(m, "domain_redirect_status"), + Path: getString(m, "domain_path"), + Account: getString(m, "domain_account"), + Server: getString(m, "domain_server"), + + DummyHost: getString(m, "dummy_host"), + DKIMSelector: getString(m, "dkim_selector"), + FPSEActive: getString(m, "fpse_active"), + PHPVersion: getString(m, "php_version"), + PHPDeprecated: getString(m, "php_deprecated"), + IsActive: getString(m, "is_active"), + InProgress: getString(m, "in_progress"), + + StatisticVersion: getInt(m, "statistic_version"), + StatisticLanguage: getString(m, "statistic_language"), + + SSL: SSL{ + Proxy: getString(m, "ssl_proxy"), + CertificateIP: getString(m, "ssl_certificate_ip"), + SNI: getString(m, "ssl_certificate_sni"), + SNIIsActive: getString(m, "ssl_certificate_sni_is_active"), + SNICSR: getString(m, "ssl_certificate_sni_csr"), + SNIKey: getString(m, "ssl_certificate_sni_key"), + SNICRT: getString(m, "ssl_certificate_sni_crt"), + SNIBundle: getString(m, "ssl_certificate_sni_bundle"), + SNIChainfile: getString(m, "ssl_certificate_sni_chainfile"), + SNIType: getString(m, "ssl_certificate_sni_type"), + SNIForceHTTPS: getString(m, "ssl_certificate_sni_force_https"), + SNIHSTSMaxAge: getString(m, "ssl_certificate_sni_hsts_max_age"), + }, + } +} + +func getString(m soap.Value, key string) string { + v, ok := m.Get(key) + if !ok { + return "" + } + return v.AsString() +} + +func getInt(m soap.Value, key string) int { + v, ok := m.Get(key) + if !ok { + return 0 + } + switch v.Kind { + case soap.KindInt: + return int(v.Int) + case soap.KindFloat: + return int(v.Float) + case soap.KindString: + s := strings.TrimSpace(v.String) + if s == "" { + return 0 + } + n, err := strconv.Atoi(s) + if err != nil { + return 0 + } + return n + } + return 0 +} + +// TableHeaders returns the columns used by --output=table for +// DomainList. +func (DomainList) TableHeaders() []string { + return []string{"DOMAIN", "ACCOUNT", "PHP", "SSL_TYPE", "ACTIVE", "IN_PROGRESS"} +} + +// TableRows emits one row per Domain entry. +func (l DomainList) TableRows() [][]string { + rows := make([][]string, 0, len(l)) + for _, d := range l { + rows = append(rows, []string{ + d.Name, + d.Account, + d.PHPVersion, + d.SSL.SNIType, + d.IsActive, + d.InProgress, + }) + } + return rows +} + +// TableHeaders returns the columns used by --output=table for TLDList. +func (TLDList) TableHeaders() []string { + return []string{"TLD", "MIN_LEN", "MAX_LEN"} +} + +// TableRows emits one row per TLD entry. +func (l TLDList) TableRows() [][]string { + rows := make([][]string, 0, len(l)) + for _, t := range l { + rows = append(rows, []string{ + t.Name, + strconv.Itoa(t.MinLen), + strconv.Itoa(t.MaxLen), + }) + } + return rows +} + +// TableHeaders for the singular Domain view: a key/value layout, since +// the record carries the SSL cert PEM blob and is too tall for a row. +func (Domain) TableHeaders() []string { + return []string{"FIELD", "VALUE"} +} + +// TableRows emits the scalar fields. The SSL cert PEM bodies are +// summarised; consumers that need them should use --output=json|yaml. +func (d Domain) TableRows() [][]string { + rows := [][]string{ + {"domain_name", d.Name}, + {"domain_tld", d.TLD}, + {"domain_account", d.Account}, + {"domain_server", d.Server}, + {"domain_path", d.Path}, + {"domain_redirect_status", strconv.Itoa(d.RedirectStatus)}, + {"dummy_host", d.DummyHost}, + {"dkim_selector", d.DKIMSelector}, + {"fpse_active", d.FPSEActive}, + {"php_version", d.PHPVersion}, + {"php_deprecated", d.PHPDeprecated}, + {"is_active", d.IsActive}, + {"in_progress", d.InProgress}, + {"statistic_version", strconv.Itoa(d.StatisticVersion)}, + {"statistic_language", d.StatisticLanguage}, + {"ssl_proxy", d.SSL.Proxy}, + {"ssl_certificate_ip", d.SSL.CertificateIP}, + {"ssl_certificate_sni", d.SSL.SNI}, + {"ssl_certificate_sni_is_active", d.SSL.SNIIsActive}, + {"ssl_certificate_sni_type", d.SSL.SNIType}, + {"ssl_certificate_sni_force_https", d.SSL.SNIForceHTTPS}, + {"ssl_certificate_sni_hsts_max_age", d.SSL.SNIHSTSMaxAge}, + {"ssl_certificate_sni_csr", summarisePEM(d.SSL.SNICSR)}, + {"ssl_certificate_sni_key", summarisePEM(d.SSL.SNIKey)}, + {"ssl_certificate_sni_crt", summarisePEM(d.SSL.SNICRT)}, + {"ssl_certificate_sni_bundle", summarisePEM(d.SSL.SNIBundle)}, + {"ssl_certificate_sni_chainfile", summarisePEM(d.SSL.SNIChainfile)}, + } + return rows +} + +// summarisePEM collapses a multi-line PEM blob to a single line marker +// so the key/value table stays readable. Empty input passes through +// unchanged. +func summarisePEM(s string) string { + if s == "" { + return "" + } + if !strings.Contains(s, "\n") { + return s + } + return fmt.Sprintf("<%d bytes, %d lines>", len(s), strings.Count(s, "\n")+1) +} diff --git a/internal/domain/domain_test.go b/internal/domain/domain_test.go new file mode 100644 index 0000000..dc811db --- /dev/null +++ b/internal/domain/domain_test.go @@ -0,0 +1,285 @@ +package domain_test + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/chmmou/kasapi-cli/internal/domain" + "github.com/chmmou/kasapi-cli/internal/soap" +) + +func repoRoot(t *testing.T) string { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + dir := filepath.Dir(file) + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatalf("repo root not found from %q", file) + } + dir = parent + } +} + +func decodeFixture(t *testing.T, name string) *soap.Response { + t.Helper() + path := filepath.Join(repoRoot(t), "testdata", "domain", name) + f, err := os.Open(path) + if err != nil { + t.Fatalf("open %s: %v", name, err) + } + defer func() { _ = f.Close() }() + resp, err := soap.Decode(f) + if err != nil { + t.Fatalf("decode %s: %v", name, err) + } + return resp +} + +type fakeCaller struct { + resp *soap.Response + err error + + gotAction string + gotParams map[string]any +} + +func (f *fakeCaller) Call(_ context.Context, action string, params map[string]any) (*soap.Response, error) { + f.gotAction = action + f.gotParams = params + return f.resp, f.err +} + +func TestDecodeDomains(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_domains_response_success.xml") + got, err := domain.DecodeDomains(resp.Body.ReturnInfo) + if err != nil { + t.Fatalf("DecodeDomains: %v", err) + } + if len(got) != 2 { + t.Fatalf("len = %d, want 2", len(got)) + } + d := got[0] + if d.Name != "example.com" || d.TLD != "com" { + t.Errorf("first domain = %q/%q, want example.com/com", d.Name, d.TLD) + } + if d.Account != "w0000000" || d.Server != "ab12345" { + t.Errorf("first placement = %q/%q", d.Account, d.Server) + } + if d.PHPVersion != "8.4" { + t.Errorf("PHPVersion = %q", d.PHPVersion) + } + if d.IsActive != "Y" { + t.Errorf("IsActive = %q", d.IsActive) + } + if d.SSL.SNI != "Y" || d.SSL.SNIType != "unknown" { + t.Errorf("SSL SNI = %+v", d.SSL) + } + // In the list view the cert PEM bodies are xsi:nil → empty string. + if d.SSL.SNICRT != "" || d.SSL.SNIKey != "" { + t.Errorf("list view leaks cert PEM: crt=%q key=%q", d.SSL.SNICRT, d.SSL.SNIKey) + } +} + +func TestDecodeDomainSingular(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_domain_response_success.xml") + got, err := domain.DecodeDomains(resp.Body.ReturnInfo) + if err != nil { + t.Fatalf("DecodeDomains: %v", err) + } + if len(got) != 1 { + t.Fatalf("len = %d, want 1", len(got)) + } + d := got[0] + if d.Name != "example.com" { + t.Errorf("Name = %q", d.Name) + } + if d.DKIMSelector != "abc190101010000" { + t.Errorf("DKIMSelector = %q", d.DKIMSelector) + } + if d.DummyHost != "N" { + t.Errorf("DummyHost = %q", d.DummyHost) + } + if d.SSL.SNIType != "LE90D" { + t.Errorf("SNIType = %q, want LE90D", d.SSL.SNIType) + } + if d.SSL.SNIForceHTTPS != "Y" { + t.Errorf("SNIForceHTTPS = %q", d.SSL.SNIForceHTTPS) + } + if d.SSL.SNIHSTSMaxAge != "-1" { + t.Errorf("SNIHSTSMaxAge = %q", d.SSL.SNIHSTSMaxAge) + } + // The singular view fills the cert PEM bodies; verify they are + // non-empty so the SSL.SNI* round-trip works. + if d.SSL.SNICRT == "" || d.SSL.SNIKey == "" || d.SSL.SNICSR == "" { + t.Errorf("singular view missing cert PEM body: csr=%q key=%q crt=%q", + d.SSL.SNICSR, d.SSL.SNIKey, d.SSL.SNICRT) + } +} + +func TestDecodeTLDs(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_topleveldomains_response_success.xml") + got, err := domain.DecodeTLDs(resp.Body.ReturnInfo) + if err != nil { + t.Fatalf("DecodeTLDs: %v", err) + } + if len(got) != 1077 { + t.Fatalf("len = %d, want 1077 (per fixture arrayType)", len(got)) + } + first := got[0] + if first.Name != "de" || first.MinLen != 1 || first.MaxLen != 63 { + t.Errorf("first = %+v, want {de,1,63}", first) + } + // Spot-check a TLD with min length > 1 to confirm the xsd:string + // → int parse path actually runs. + for _, tld := range got { + if tld.Name == "info" { + if tld.MinLen != 3 { + t.Errorf("info.MinLen = %d, want 3", tld.MinLen) + } + break + } + } +} + +func TestClientList(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_domains_response_success.xml") + fc := &fakeCaller{resp: resp} + list, err := domain.NewClient(fc).List(context.Background()) + if err != nil { + t.Fatalf("List: %v", err) + } + if fc.gotAction != "get_domains" { + t.Errorf("action = %q, want get_domains", fc.gotAction) + } + if fc.gotParams != nil { + t.Errorf("params = %v, want nil", fc.gotParams) + } + if len(list) != 2 { + t.Errorf("len = %d, want 2", len(list)) + } +} + +func TestClientGet(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_domain_response_success.xml") + fc := &fakeCaller{resp: resp} + d, err := domain.NewClient(fc).Get(context.Background(), "example.com") + if err != nil { + t.Fatalf("Get: %v", err) + } + if fc.gotAction != "get_domains" { + t.Errorf("action = %q, want get_domains", fc.gotAction) + } + if name, _ := fc.gotParams["domain_name"].(string); name != "example.com" { + t.Errorf("params[domain_name] = %v, want example.com", fc.gotParams["domain_name"]) + } + if d.Name != "example.com" { + t.Errorf("Name = %q", d.Name) + } +} + +func TestClientGetEmptyName(t *testing.T) { + t.Parallel() + c := domain.NewClient(&fakeCaller{}) + if _, err := c.Get(context.Background(), ""); err == nil { + t.Errorf("Get(\"\") err = nil, want validation error") + } +} + +func TestClientGetNotFound(t *testing.T) { + t.Parallel() + // Empty array fixture: re-use get_domains_request via a hand-made + // soap.Response with a zero-length array. + resp := &soap.Response{Body: soap.ResponseBody{ReturnInfo: soap.Value{Kind: soap.KindArray}}} + c := domain.NewClient(&fakeCaller{resp: resp}) + if _, err := c.Get(context.Background(), "missing.example"); err == nil { + t.Errorf("Get on empty result err = nil, want not-found") + } +} + +func TestClientTopLevelDomains(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_topleveldomains_response_success.xml") + fc := &fakeCaller{resp: resp} + list, err := domain.NewClient(fc).TopLevelDomains(context.Background()) + if err != nil { + t.Fatalf("TopLevelDomains: %v", err) + } + if fc.gotAction != "get_topleveldomains" { + t.Errorf("action = %q, want get_topleveldomains", fc.gotAction) + } + if len(list) != 1077 { + t.Errorf("len = %d, want 1077", len(list)) + } +} + +func TestClientPropagatesError(t *testing.T) { + t.Parallel() + want := errors.New("boom") + c := domain.NewClient(&fakeCaller{err: want}) + if _, err := c.List(context.Background()); !errors.Is(err, want) { + t.Errorf("List err = %v, want %v wrapped", err, want) + } + if _, err := c.Get(context.Background(), "x.example"); !errors.Is(err, want) { + t.Errorf("Get err = %v, want %v wrapped", err, want) + } + if _, err := c.TopLevelDomains(context.Background()); !errors.Is(err, want) { + t.Errorf("TopLevelDomains err = %v, want %v wrapped", err, want) + } +} + +func TestDomainListTabular(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_domains_response_success.xml") + list, _ := domain.DecodeDomains(resp.Body.ReturnInfo) + rows := list.TableRows() + if len(rows) != 2 { + t.Fatalf("rows = %d, want 2", len(rows)) + } + if rows[0][0] != "example.com" || rows[0][1] != "w0000000" { + t.Errorf("rows[0] = %v", rows[0]) + } +} + +func TestTLDListTabular(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_topleveldomains_response_success.xml") + list, _ := domain.DecodeTLDs(resp.Body.ReturnInfo) + rows := list.TableRows() + if len(rows) != 1077 { + t.Fatalf("rows = %d, want 1077", len(rows)) + } + if rows[0][0] != "de" || rows[0][1] != "1" || rows[0][2] != "63" { + t.Errorf("rows[0] = %v", rows[0]) + } +} + +func TestDomainTabularSummarisesPEM(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_domain_response_success.xml") + list, _ := domain.DecodeDomains(resp.Body.ReturnInfo) + rows := list[0].TableRows() + for _, row := range rows { + if row[0] == "ssl_certificate_sni_crt" && row[1] != "" { + // Multi-line PEM blob must be summarised, not pasted in. + if row[1][0] != '<' { + t.Errorf("ssl_certificate_sni_crt row not summarised: %q", row[1]) + } + } + } +} diff --git a/internal/subdomain/subdomain.go b/internal/subdomain/subdomain.go new file mode 100644 index 0000000..4db9c7e --- /dev/null +++ b/internal/subdomain/subdomain.go @@ -0,0 +1,183 @@ +package subdomain + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/chmmou/kasapi-cli/internal/soap" +) + +// Caller is the subset of *api.Client this package depends on. The +// indirection keeps tests free of network setup: a fake Caller can +// return a *soap.Response decoded from a fixture. +type Caller interface { + Call(ctx context.Context, action string, params map[string]any) (*soap.Response, error) +} + +// Subdomain is one entry of get_subdomains. The shape mirrors the +// domain list view (same SSL summary, same lifecycle flags) but is +// keyed on subdomain_* fields. +type Subdomain struct { + Name string `json:"subdomain_name" yaml:"subdomain_name"` + RedirectStatus int `json:"subdomain_redirect_status" yaml:"subdomain_redirect_status"` + Path string `json:"subdomain_path" yaml:"subdomain_path"` + Account string `json:"subdomain_account" yaml:"subdomain_account"` + Server string `json:"subdomain_server" yaml:"subdomain_server"` + + FPSEActive string `json:"fpse_active" yaml:"fpse_active"` + PHPVersion string `json:"php_version" yaml:"php_version"` + PHPDeprecated string `json:"php_deprecated" yaml:"php_deprecated"` + IsActive string `json:"is_active" yaml:"is_active"` + InProgress string `json:"in_progress" yaml:"in_progress"` + + StatisticVersion int `json:"statistic_version" yaml:"statistic_version"` + StatisticLanguage string `json:"statistic_language" yaml:"statistic_language"` + + SSL SSL `json:"ssl" yaml:"ssl"` +} + +// SSL groups the ssl_* keys returned for a subdomain. The list view +// leaves the cert/key/CSR bodies as xsi:nil, decoded here as the +// empty string. +type SSL struct { + Proxy string `json:"proxy" yaml:"proxy"` + CertificateIP string `json:"certificate_ip" yaml:"certificate_ip"` + SNI string `json:"sni" yaml:"sni"` + SNIIsActive string `json:"sni_is_active" yaml:"sni_is_active"` + SNICSR string `json:"sni_csr,omitempty" yaml:"sni_csr,omitempty"` + SNIKey string `json:"sni_key,omitempty" yaml:"sni_key,omitempty"` + SNICRT string `json:"sni_crt,omitempty" yaml:"sni_crt,omitempty"` + SNIBundle string `json:"sni_bundle,omitempty" yaml:"sni_bundle,omitempty"` + SNIChainfile string `json:"sni_chainfile,omitempty" yaml:"sni_chainfile,omitempty"` + SNIType string `json:"sni_type,omitempty" yaml:"sni_type,omitempty"` + SNIForceHTTPS string `json:"sni_force_https,omitempty" yaml:"sni_force_https,omitempty"` + SNIHSTSMaxAge string `json:"sni_hsts_max_age,omitempty" yaml:"sni_hsts_max_age,omitempty"` +} + +// SubdomainList is the typed payload of get_subdomains; satisfies +// cli.Tabular. +type SubdomainList []Subdomain + +// Client groups the read endpoints scoped to subdomains: get_subdomains. +type Client struct { + API Caller +} + +// NewClient returns a Client backed by the given Caller. +func NewClient(c Caller) *Client { return &Client{API: c} } + +// List calls get_subdomains and decodes the response into a +// SubdomainList. The KAS API accepts no parameters on this endpoint. +func (c *Client) List(ctx context.Context) (SubdomainList, error) { + resp, err := c.API.Call(ctx, "get_subdomains", nil) + if err != nil { + return nil, err + } + list, err := DecodeSubdomains(resp.Body.ReturnInfo) + if err != nil { + return nil, fmt.Errorf("subdomain: get_subdomains: %w", err) + } + return list, nil +} + +// DecodeSubdomains maps the ReturnInfo of a get_subdomains response +// (an Array of Maps) into the typed SubdomainList. +func DecodeSubdomains(returnInfo soap.Value) (SubdomainList, error) { + if returnInfo.Kind != soap.KindArray { + return nil, fmt.Errorf("subdomain: expected ReturnInfo array, got kind %d", returnInfo.Kind) + } + out := make(SubdomainList, 0, len(returnInfo.Array)) + for i, item := range returnInfo.Array { + if item.Kind != soap.KindMap { + return nil, fmt.Errorf("subdomain: ReturnInfo[%d] is not a Map", i) + } + out = append(out, Subdomain{ + Name: getString(item, "subdomain_name"), + RedirectStatus: getInt(item, "subdomain_redirect_status"), + Path: getString(item, "subdomain_path"), + Account: getString(item, "subdomain_account"), + Server: getString(item, "subdomain_server"), + + FPSEActive: getString(item, "fpse_active"), + PHPVersion: getString(item, "php_version"), + PHPDeprecated: getString(item, "php_deprecated"), + IsActive: getString(item, "is_active"), + InProgress: getString(item, "in_progress"), + + StatisticVersion: getInt(item, "statistic_version"), + StatisticLanguage: getString(item, "statistic_language"), + + SSL: SSL{ + Proxy: getString(item, "ssl_proxy"), + CertificateIP: getString(item, "ssl_certificate_ip"), + SNI: getString(item, "ssl_certificate_sni"), + SNIIsActive: getString(item, "ssl_certificate_sni_is_active"), + SNICSR: getString(item, "ssl_certificate_sni_csr"), + SNIKey: getString(item, "ssl_certificate_sni_key"), + SNICRT: getString(item, "ssl_certificate_sni_crt"), + SNIBundle: getString(item, "ssl_certificate_sni_bundle"), + SNIChainfile: getString(item, "ssl_certificate_sni_chainfile"), + SNIType: getString(item, "ssl_certificate_sni_type"), + SNIForceHTTPS: getString(item, "ssl_certificate_sni_force_https"), + SNIHSTSMaxAge: getString(item, "ssl_certificate_sni_hsts_max_age"), + }, + }) + } + return out, nil +} + +func getString(m soap.Value, key string) string { + v, ok := m.Get(key) + if !ok { + return "" + } + return v.AsString() +} + +func getInt(m soap.Value, key string) int { + v, ok := m.Get(key) + if !ok { + return 0 + } + switch v.Kind { + case soap.KindInt: + return int(v.Int) + case soap.KindFloat: + return int(v.Float) + case soap.KindString: + s := strings.TrimSpace(v.String) + if s == "" { + return 0 + } + n, err := strconv.Atoi(s) + if err != nil { + return 0 + } + return n + } + return 0 +} + +// TableHeaders returns the columns used by --output=table for +// SubdomainList. +func (SubdomainList) TableHeaders() []string { + return []string{"SUBDOMAIN", "ACCOUNT", "PHP", "SSL_TYPE", "ACTIVE", "IN_PROGRESS"} +} + +// TableRows emits one row per Subdomain entry. +func (l SubdomainList) TableRows() [][]string { + rows := make([][]string, 0, len(l)) + for _, s := range l { + rows = append(rows, []string{ + s.Name, + s.Account, + s.PHPVersion, + s.SSL.SNIType, + s.IsActive, + s.InProgress, + }) + } + return rows +} diff --git a/internal/subdomain/subdomain_test.go b/internal/subdomain/subdomain_test.go new file mode 100644 index 0000000..321e9bb --- /dev/null +++ b/internal/subdomain/subdomain_test.go @@ -0,0 +1,127 @@ +package subdomain_test + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/chmmou/kasapi-cli/internal/soap" + "github.com/chmmou/kasapi-cli/internal/subdomain" +) + +func repoRoot(t *testing.T) string { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + dir := filepath.Dir(file) + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatalf("repo root not found from %q", file) + } + dir = parent + } +} + +func decodeFixture(t *testing.T, name string) *soap.Response { + t.Helper() + path := filepath.Join(repoRoot(t), "testdata", "subdomain", name) + f, err := os.Open(path) + if err != nil { + t.Fatalf("open %s: %v", name, err) + } + defer func() { _ = f.Close() }() + resp, err := soap.Decode(f) + if err != nil { + t.Fatalf("decode %s: %v", name, err) + } + return resp +} + +type fakeCaller struct { + resp *soap.Response + err error + + gotAction string + gotParams map[string]any +} + +func (f *fakeCaller) Call(_ context.Context, action string, params map[string]any) (*soap.Response, error) { + f.gotAction = action + f.gotParams = params + return f.resp, f.err +} + +func TestDecodeSubdomains(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_subdomains_response_success.xml") + got, err := subdomain.DecodeSubdomains(resp.Body.ReturnInfo) + if err != nil { + t.Fatalf("DecodeSubdomains: %v", err) + } + if len(got) != 2 { + t.Fatalf("len = %d, want 2", len(got)) + } + first := got[0] + if first.Name != "sub1.example.com" { + t.Errorf("Name = %q", first.Name) + } + if first.Account != "w0000000" || first.Server != "ab12345" { + t.Errorf("placement = %q/%q", first.Account, first.Server) + } + if first.PHPVersion != "8.4" || first.IsActive != "Y" { + t.Errorf("first php/active = %q/%q", first.PHPVersion, first.IsActive) + } + if first.SSL.SNI != "Y" || first.SSL.SNIType != "unknown" { + t.Errorf("first SSL = %+v", first.SSL) + } +} + +func TestClientList(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_subdomains_response_success.xml") + fc := &fakeCaller{resp: resp} + list, err := subdomain.NewClient(fc).List(context.Background()) + if err != nil { + t.Fatalf("List: %v", err) + } + if fc.gotAction != "get_subdomains" { + t.Errorf("action = %q, want get_subdomains", fc.gotAction) + } + if fc.gotParams != nil { + t.Errorf("params = %v, want nil", fc.gotParams) + } + if len(list) != 2 { + t.Errorf("len = %d, want 2", len(list)) + } +} + +func TestClientPropagatesError(t *testing.T) { + t.Parallel() + want := errors.New("boom") + c := subdomain.NewClient(&fakeCaller{err: want}) + if _, err := c.List(context.Background()); !errors.Is(err, want) { + t.Errorf("List err = %v, want %v wrapped", err, want) + } +} + +func TestSubdomainListTabular(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_subdomains_response_success.xml") + list, _ := subdomain.DecodeSubdomains(resp.Body.ReturnInfo) + rows := list.TableRows() + if len(rows) != 2 { + t.Fatalf("rows = %d, want 2", len(rows)) + } + if rows[0][0] != "sub1.example.com" || rows[0][1] != "w0000000" { + t.Errorf("rows[0] = %v", rows[0]) + } +}