From d258bcc15aecfc242b3cf71a3e89dab797975f88 Mon Sep 17 00:00:00 2001 From: Alexander Saal Date: Fri, 8 May 2026 22:10:18 +0200 Subject: [PATCH] feat: accounts get via get_accounts filter Add account.Client.Get(ctx, login) calling get_accounts with an account_login filter; unwrap the single-entry array and surface an empty result as a not-found error, mirroring mailaccount.Client.Get. Rename the existing accounts get (get_accountsettings) to accounts settings so accounts get can take an argument. This matches the established mail accounts list|get pattern. Add a singular Tabular layout on Account for the key/value detail view, plus a new singular fixture testdata/account/get_account_response_success.xml derived from the existing list fixture. --- CHANGELOG.md | 17 ++ ROADMAP.md | 3 +- internal/account/account_test.go | 91 +++++++- internal/account/client.go | 28 ++- internal/account/table.go | 43 ++++ internal/cli/account.go | 30 ++- internal/cli/account_test.go | 2 +- .../account/get_account_response_success.xml | 199 ++++++++++++++++++ 8 files changed, 398 insertions(+), 15 deletions(-) create mode 100644 testdata/account/get_account_response_success.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a43c6d..ae74265 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `account.Client.Get(ctx, login)` and `kasapi-cli accounts get + ` calling `get_accounts` with an `account_login` + filter. The result is unwrapped from the single-entry array so the + CLI can render a key/value detail view; an empty array surfaces as a + not-found error. Mapping test runs against + `testdata/account/get_account_response_success.xml`. + - `internal/mailinglist` read module and `kasapi-cli mail lists list` subcommand wrapping `get_mailinglists`. Decodes the Array of `{mailinglist_name, mailinglist_admin, mailinglist_url, in_progress}` @@ -93,6 +100,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 with the server-side window. Practical effect: rerun a command and no `--otp` prompt is needed for as long as the session is alive. +### Changed + +- `kasapi-cli accounts get` was renamed to `kasapi-cli accounts + settings`; the old name now wraps `get_accounts` with the + `account_login` filter (see Added), matching the `mail accounts + list|get` pattern. The `accounts list` short description was + tightened to clarify that an unfiltered `get_accounts` returns every + account visible to the login (every sub-account for a main login, + just the login itself for a sub-account). + ### Fixed - `internal/usage`: drop the action name from `DecodeSpace` / diff --git a/ROADMAP.md b/ROADMAP.md index f13b232..f0137ed 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -21,7 +21,8 @@ The list is kept in sync with the code on `main`. To claim an unchecked item, pl ## Accounts & server -- [x] `account get` (`get_accounts`, `get_accountsettings`, `get_accountresources`) +- [x] `accounts list` / `accounts get ` (`get_accounts`, with `account_login` filter) +- [x] `accounts settings` / `accounts resources` (`get_accountsettings`, `get_accountresources`) - [x] `server get` (`get_server_information`) ## Usage diff --git a/internal/account/account_test.go b/internal/account/account_test.go index 422175b..77c9fbf 100644 --- a/internal/account/account_test.go +++ b/internal/account/account_test.go @@ -144,37 +144,94 @@ func TestDecodeAccountResources(t *testing.T) { } } -// fakeCaller returns the response decoded from a fixture, ignoring the -// requested action and params. It is enough to exercise Client.List / -// Settings / Resources without a network roundtrip. +// fakeCaller returns the response decoded from a fixture and records +// the action / params it was called with. This is enough to exercise +// Client.List / Get / Settings / Resources without a network +// roundtrip. type fakeCaller struct { resp *soap.Response err error + + gotAction string + gotParams map[string]any } -func (f fakeCaller) Call(_ context.Context, _ string, _ map[string]any) (*soap.Response, error) { +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 TestClientList(t *testing.T) { t.Parallel() resp := decodeFixture(t, "get_accounts_response_success.xml") - c := account.NewClient(fakeCaller{resp: resp}) - got, err := c.List(context.Background()) + fc := &fakeCaller{resp: resp} + got, err := account.NewClient(fc).List(context.Background()) if err != nil { t.Fatalf("List: %v", err) } + if fc.gotAction != "get_accounts" { + t.Errorf("action = %q, want get_accounts", fc.gotAction) + } + if fc.gotParams != nil { + t.Errorf("params = %v, want nil", fc.gotParams) + } if len(got) != 4 { t.Errorf("len = %d, want 4", len(got)) } } +func TestClientGet(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_account_response_success.xml") + fc := &fakeCaller{resp: resp} + got, err := account.NewClient(fc).Get(context.Background(), "w0000001") + if err != nil { + t.Fatalf("Get: %v", err) + } + if fc.gotAction != "get_accounts" { + t.Errorf("action = %q, want get_accounts", fc.gotAction) + } + if v, ok := fc.gotParams["account_login"]; !ok || v != "w0000001" { + t.Errorf("account_login param = %v (ok=%v), want w0000001", v, ok) + } + if got.Login != "w0000001" { + t.Errorf("Login = %q, want w0000001", got.Login) + } + if got.MaxAccount != 10 { + t.Errorf("MaxAccount = %d, want 10", got.MaxAccount) + } +} + +func TestClientGetEmptyLogin(t *testing.T) { + t.Parallel() + c := account.NewClient(&fakeCaller{}) + if _, err := c.Get(context.Background(), ""); err == nil { + t.Error("Get(\"\") returned nil, want error") + } +} + +func TestClientGetNotFound(t *testing.T) { + t.Parallel() + // Synthesise an empty array response by reusing the singular + // fixture but with the array stripped to zero entries. + emptyResp := decodeFixture(t, "get_account_response_success.xml") + emptyResp.Body.ReturnInfo.Array = nil + c := account.NewClient(&fakeCaller{resp: emptyResp}) + if _, err := c.Get(context.Background(), "wXXXXXXX"); err == nil { + t.Error("Get on empty array returned nil, want not-found error") + } +} + func TestClientPropagatesError(t *testing.T) { t.Parallel() want := errors.New("boom") - c := account.NewClient(fakeCaller{err: want}) + c := account.NewClient(&fakeCaller{err: want}) if _, err := c.List(context.Background()); !errors.Is(err, want) { - t.Errorf("err = %v, want %v wrapped", err, want) + t.Errorf("List err = %v, want %v wrapped", err, want) + } + if _, err := c.Get(context.Background(), "w0000001"); !errors.Is(err, want) { + t.Errorf("Get err = %v, want %v wrapped", err, want) } } @@ -196,6 +253,24 @@ func TestAccountListTabular(t *testing.T) { } } +func TestAccountTabular(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_account_response_success.xml") + accs, _ := account.DecodeAccounts(resp.Body.ReturnInfo) + if len(accs) != 1 { + t.Fatalf("len = %d, want 1", len(accs)) + } + a := accs[0] + headers := a.TableHeaders() + if headers[0] != "FIELD" || headers[1] != "VALUE" { + t.Errorf("headers = %v, want [FIELD VALUE]", headers) + } + rows := a.TableRows() + if rows[0][0] != "account_login" || rows[0][1] != "w0000001" { + t.Errorf("rows[0] = %v, want [account_login w0000001]", rows[0]) + } +} + func TestAccountResourcesTabular(t *testing.T) { t.Parallel() resp := decodeFixture(t, "get_accountresources_response_success.xml") diff --git a/internal/account/client.go b/internal/account/client.go index a8a19a5..ef4823a 100644 --- a/internal/account/client.go +++ b/internal/account/client.go @@ -24,8 +24,10 @@ type Client struct { // retain ownership of c. func NewClient(c Caller) *Client { return &Client{API: c} } -// List calls get_accounts and decodes the response into a slice of -// Account values. +// List calls get_accounts without a filter and decodes the response +// into a slice of Account values. For a main login this returns every +// sub-account; for a sub-login it returns just the authenticated +// account. func (c *Client) List(ctx context.Context) ([]Account, error) { resp, err := c.API.Call(ctx, "get_accounts", nil) if err != nil { @@ -38,6 +40,28 @@ func (c *Client) List(ctx context.Context) ([]Account, error) { return accs, nil } +// Get calls get_accounts with an account_login filter and returns the +// single matching Account. 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, login string) (Account, error) { + if login == "" { + return Account{}, fmt.Errorf("account: login is required") + } + resp, err := c.API.Call(ctx, "get_accounts", map[string]any{"account_login": login}) + if err != nil { + return Account{}, err + } + accs, err := DecodeAccounts(resp.Body.ReturnInfo) + if err != nil { + return Account{}, fmt.Errorf("account: get_accounts: %w", err) + } + if len(accs) == 0 { + return Account{}, fmt.Errorf("account: %q not found", login) + } + return accs[0], nil +} + // Settings calls get_accountsettings and decodes the response into the // AccountSettings struct for the authenticated login. func (c *Client) Settings(ctx context.Context) (AccountSettings, error) { diff --git a/internal/account/table.go b/internal/account/table.go index fb1f716..205e1a5 100644 --- a/internal/account/table.go +++ b/internal/account/table.go @@ -102,6 +102,49 @@ func (s AccountSettings) TableRows() [][]string { return rows } +// TableHeaders for the singular Account view: a key/value layout to +// fit the wide field set returned by get_accounts with an +// account_login filter. +func (Account) TableHeaders() []string { + return []string{"FIELD", "VALUE"} +} + +// TableRows emits the scalar fields. account_password is intentionally +// omitted — consumers that need it should use --output=json|yaml. +func (a Account) TableRows() [][]string { + return [][]string{ + {"account_login", a.Login}, + {"account_comment", a.AccountComment}, + {"account_contact_mail", a.AccountContactMail}, + {"max_account", strconv.Itoa(a.MaxAccount)}, + {"max_domain", strconv.Itoa(a.MaxDomain)}, + {"max_subdomain", strconv.Itoa(a.MaxSubdomain)}, + {"max_webspace", strconv.Itoa(a.MaxWebspace)}, + {"max_mail_account", strconv.Itoa(a.MaxMailAccount)}, + {"max_mail_forward", strconv.Itoa(a.MaxMailForward)}, + {"max_mail_list", strconv.Itoa(a.MaxMailList)}, + {"max_databases", strconv.Itoa(a.MaxDatabases)}, + {"max_ftpuser", strconv.Itoa(a.MaxFTPUser)}, + {"max_sambauser", strconv.Itoa(a.MaxSambaUser)}, + {"max_cronjobs", strconv.Itoa(a.MaxCronjobs)}, + {"max_wbk", strconv.Itoa(a.MaxWBK)}, + {"used_account_space", strconv.FormatFloat(a.UsedAccountSpace/1024, 'f', 1, 64) + " MB"}, + {"inst_htaccess", a.InstHtaccess}, + {"inst_fpse", a.InstFPSE}, + {"inst_software", a.InstSoftware}, + {"kas_access_forbidden", a.KASAccessForbid}, + {"logging", a.Logging}, + {"logage", strconv.Itoa(a.Logage)}, + {"statistic", a.Statistic}, + {"dns_settings", a.DNSSettings}, + {"ssh_access", a.SSHAccess}, + {"show_password", a.ShowPassword}, + {"account_2fa", a.Account2FA}, + {"account_2fa_inherited", a.Account2FAInherited}, + {"in_progress", a.InProgress}, + } +} + // quotaInt formats -1 (KAS sentinel for "unlimited") as the symbol "∞" // so table users do not have to know the convention. func quotaInt(n int) string { diff --git a/internal/cli/account.go b/internal/cli/account.go index 1296146..3b07335 100644 --- a/internal/cli/account.go +++ b/internal/cli/account.go @@ -7,7 +7,8 @@ import ( ) // NewAccountCmd returns the "kasapi-cli accounts" subcommand tree: -// list (get_accounts), get (get_accountsettings), and resources +// list and get (both get_accounts, the latter with an account_login +// filter), settings (get_accountsettings), and resources // (get_accountresources). func NewAccountCmd(opts *RootOptions) *cobra.Command { cmd := &cobra.Command{ @@ -17,6 +18,7 @@ func NewAccountCmd(opts *RootOptions) *cobra.Command { cmd.AddCommand( newAccountsListCmd(opts), newAccountsGetCmd(opts), + newAccountsSettingsCmd(opts), newAccountsResourcesCmd(opts), ) return cmd @@ -25,7 +27,7 @@ func NewAccountCmd(opts *RootOptions) *cobra.Command { func newAccountsListCmd(opts *RootOptions) *cobra.Command { return &cobra.Command{ Use: "list", - Short: "List all sub-accounts (get_accounts)", + Short: "List accounts visible to the login (get_accounts, no filter)", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { api, err := BuildAPIClient(opts) @@ -46,7 +48,29 @@ func newAccountsListCmd(opts *RootOptions) *cobra.Command { func newAccountsGetCmd(opts *RootOptions) *cobra.Command { return &cobra.Command{ - Use: "get", + Use: "get ", + Short: "Show details for a single account (get_accounts with account_login)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + api, err := BuildAPIClient(opts) + if err != nil { + return err + } + a, err := account.NewClient(api).Get(cmd.Context(), args[0]) + if err != nil { + return APIError(err, "get_accounts") + } + if err := Render(cmd.OutOrStdout(), opts.Output, a); err != nil { + return UserError(err, "render") + } + return nil + }, + } +} + +func newAccountsSettingsCmd(opts *RootOptions) *cobra.Command { + return &cobra.Command{ + Use: "settings", Short: "Show settings for the authenticated account (get_accountsettings)", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { diff --git a/internal/cli/account_test.go b/internal/cli/account_test.go index 6848f75..c0daa36 100644 --- a/internal/cli/account_test.go +++ b/internal/cli/account_test.go @@ -21,7 +21,7 @@ func TestAccountCmdHelpListsSubcommands(t *testing.T) { t.Fatalf("Execute: %v", err) } out := buf.String() - for _, want := range []string{"list", "get", "resources"} { + for _, want := range []string{"list", "get", "settings", "resources"} { if !strings.Contains(out, want) { t.Errorf("--help output missing %q\n%s", want, out) } diff --git a/testdata/account/get_account_response_success.xml b/testdata/account/get_account_response_success.xml new file mode 100644 index 0000000..de4ecee --- /dev/null +++ b/testdata/account/get_account_response_success.xml @@ -0,0 +1,199 @@ + + + + + + + Request + + + KasRequestTime + 1772291032 + + + KasRequestType + get_accounts + + + KasRequestParams + + + account_login + w0000001 + + + + + + + Response + + + KasFloodDelay + 0.5 + + + ReturnString + TRUE + + + ReturnInfo + + + + account_login + w0000001 + + + account_password + REDACTED + + + max_account + 10 + + + max_domain + 4 + + + max_subdomain + 10 + + + max_webspace + 35600 + + + max_mail_account + 35 + + + max_mail_forward + 35 + + + max_mail_list + 10 + + + max_databases + 10 + + + max_ftpuser + 10 + + + max_sambauser + 0 + + + max_cronjobs + 10 + + + max_wbk + 0 + + + inst_htaccess + Y + + + inst_fpse + N + + + inst_software + Y + + + kas_access_forbidden + N + + + logging + voll + + + statistic + de + + + logage + 999 + + + show_password + N + + + dns_settings + Y + + + show_direct_links + 0 + + + ssh_access + Y + + + used_account_space + 4914929.85449 + + + account_2fa + Y + + + account_2fa_inherited + N + + + show_direct_links_wbk + Y + + + show_direct_links_sambausers + Y + + + show_direct_links_accounts + Y + + + show_direct_links_mailaccounts + Y + + + show_direct_links_ftpuser + Y + + + show_direct_links_databases + Y + + + account_comment + my_account_comment + + + account_contact_mail + noreply@example.org + + + in_progress + FALSE + + + + + + + + + +