Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<account-login>` 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}`
Expand Down Expand Up @@ -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` /
Expand Down
3 changes: 2 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <account-login>` (`get_accounts`, with `account_login` filter)
- [x] `accounts settings` / `accounts resources` (`get_accountsettings`, `get_accountresources`)
- [x] `server get` (`get_server_information`)

## Usage
Expand Down
91 changes: 83 additions & 8 deletions internal/account/account_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand All @@ -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")
Expand Down
28 changes: 26 additions & 2 deletions internal/account/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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) {
Expand Down
43 changes: 43 additions & 0 deletions internal/account/table.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
30 changes: 27 additions & 3 deletions internal/cli/account.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -17,6 +18,7 @@ func NewAccountCmd(opts *RootOptions) *cobra.Command {
cmd.AddCommand(
newAccountsListCmd(opts),
newAccountsGetCmd(opts),
newAccountsSettingsCmd(opts),
newAccountsResourcesCmd(opts),
)
return cmd
Expand All @@ -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)
Expand All @@ -46,7 +48,29 @@ func newAccountsListCmd(opts *RootOptions) *cobra.Command {

func newAccountsGetCmd(opts *RootOptions) *cobra.Command {
return &cobra.Command{
Use: "get",
Use: "get <account-login>",
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 {
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/account_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Loading
Loading