diff --git a/CHANGELOG.md b/CHANGELOG.md index be71d80..3cc093f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `internal/ftpuser` read module and `kasapi-cli ftpusers list|get` + subcommand tree wrapping `get_ftpusers`. The list variant decodes + the Array of Maps into a typed `FTPUserList`; `get ` + reuses the same endpoint with an `ftp_login` filter and unwraps the + single-entry result. The list view shows login, path, comment, + main-user flag, the three permission flags (R/W/L), the ClamAV + scan flag, and `in_progress`; the singular view falls back to a + key/value table and omits `ftp_password` / `ftp_passwort` (still + available via `--output=json|yaml`). Mapping tests run against + `testdata/ftpuser/get_ftpusers_response_success.xml`, + `get_ftpuser_response_success.xml`, and the empty-list fixture + (`get_ftpuser_response_success_empty_list.xml`). Refs #11. + - `internal/database` read module and `kasapi-cli databases list|get` subcommand tree wrapping `get_databases`. The list variant decodes the Array of Maps into a typed `DatabaseList`; `get ` diff --git a/ROADMAP.md b/ROADMAP.md index bc7bdc8..0d2ba8b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -56,7 +56,8 @@ The list is kept in sync with the code on `main`. To claim an unchecked item, pl - [x] `databases list` / `databases get ` (`get_databases`, with `database_login` filter) - [ ] Database write paths (`add_database`, `update_database`, `delete_database`) -- [ ] FTP users (`get_ftpusers`, `add_ftpuser`, `update_ftpuser`, `delete_ftpuser`) +- [x] `ftpusers list` / `ftpusers get ` (`get_ftpusers`, with `ftp_login` filter) +- [ ] FTP user write paths (`add_ftpuser`, `update_ftpuser`, `delete_ftpuser`) - [ ] Samba users (`get_sambausers`, `add_sambauser`, `update_sambauser`, `delete_sambauser`) - [ ] DDNS users (`get_ddnsusers`, `add_ddnsuser`, `update_ddnsuser`, `delete_ddnsuser`) - [ ] Cronjobs (`get_cronjobs`, `add_cronjob`, `update_cronjob`, `delete_cronjob`) diff --git a/cmd/kasapi-cli/main.go b/cmd/kasapi-cli/main.go index 5f5467b..7ae7e8d 100644 --- a/cmd/kasapi-cli/main.go +++ b/cmd/kasapi-cli/main.go @@ -23,6 +23,7 @@ func main() { cli.NewDNSCmd(opts), cli.NewMailCmd(opts), cli.NewDatabasesCmd(opts), + cli.NewFTPUsersCmd(opts), cli.NewUsageCmd(opts), cli.NewConfigCmd(opts), ) diff --git a/internal/cli/ftpusers.go b/internal/cli/ftpusers.go new file mode 100644 index 0000000..4f19827 --- /dev/null +++ b/internal/cli/ftpusers.go @@ -0,0 +1,66 @@ +package cli + +import ( + "github.com/spf13/cobra" + + "github.com/chmmou/kasapi-cli/internal/ftpuser" +) + +// NewFTPUsersCmd returns the "kasapi-cli ftpusers" subcommand tree: +// list (get_ftpusers, no filter) and get (get_ftpusers +// with an ftp_login filter). +func NewFTPUsersCmd(opts *RootOptions) *cobra.Command { + cmd := &cobra.Command{ + Use: "ftpusers", + Short: "Inspect FTP users visible to the login (get_ftpusers)", + } + cmd.AddCommand( + newFTPUsersListCmd(opts), + newFTPUsersGetCmd(opts), + ) + return cmd +} + +func newFTPUsersListCmd(opts *RootOptions) *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List all FTP users (get_ftpusers, no filter)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + api, err := BuildAPIClient(opts) + if err != nil { + return err + } + list, err := ftpuser.NewClient(api).List(cmd.Context()) + if err != nil { + return APIError(err, "get_ftpusers") + } + if err := Render(cmd.OutOrStdout(), opts.Output, list); err != nil { + return UserError(err, "render") + } + return nil + }, + } +} + +func newFTPUsersGetCmd(opts *RootOptions) *cobra.Command { + return &cobra.Command{ + Use: "get ", + Short: "Show details for a single FTP user (get_ftpusers with ftp_login)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + api, err := BuildAPIClient(opts) + if err != nil { + return err + } + u, err := ftpuser.NewClient(api).Get(cmd.Context(), args[0]) + if err != nil { + return APIError(err, "get_ftpusers") + } + if err := Render(cmd.OutOrStdout(), opts.Output, u); err != nil { + return UserError(err, "render") + } + return nil + }, + } +} diff --git a/internal/cli/ftpusers_test.go b/internal/cli/ftpusers_test.go new file mode 100644 index 0000000..70b9f36 --- /dev/null +++ b/internal/cli/ftpusers_test.go @@ -0,0 +1,29 @@ +package cli_test + +import ( + "bytes" + "strings" + "testing" + + "github.com/chmmou/kasapi-cli/internal/cli" +) + +func TestFTPUsersCmdHelpListsSubcommands(t *testing.T) { + t.Parallel() + root, opts := cli.NewRootCmd() + root.AddCommand(cli.NewFTPUsersCmd(opts)) + + var buf bytes.Buffer + root.SetOut(&buf) + root.SetErr(&buf) + root.SetArgs([]string{"ftpusers", "--help"}) + if err := root.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + out := buf.String() + for _, want := range []string{"list", "get"} { + if !strings.Contains(out, want) { + t.Errorf("--help output missing %q\n%s", want, out) + } + } +} diff --git a/internal/ftpuser/ftpuser.go b/internal/ftpuser/ftpuser.go new file mode 100644 index 0000000..2b6b140 --- /dev/null +++ b/internal/ftpuser/ftpuser.go @@ -0,0 +1,174 @@ +package ftpuser + +import ( + "context" + "fmt" + + "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) +} + +// FTPUser is one entry of get_ftpusers. List and singular views (the +// latter being get_ftpusers called with an ftp_login filter) return +// the same Map shape, so a single struct covers both. +// +// The KAS API returns both the canonical ftp_password and the legacy +// German ftp_passwort key with identical content; both are kept in +// the struct so a JSON/YAML round-trip preserves the raw payload. +// Consumers should prefer Password. +type FTPUser struct { + Login string `json:"ftp_login" yaml:"ftp_login"` + Password string `json:"ftp_password,omitempty" yaml:"ftp_password,omitempty"` + Passwort string `json:"ftp_passwort,omitempty" yaml:"ftp_passwort,omitempty"` + + Path string `json:"ftp_path" yaml:"ftp_path"` + Comment string `json:"ftp_comment" yaml:"ftp_comment"` + IsMainUser string `json:"ftp_is_main_user" yaml:"ftp_is_main_user"` + + PermissionList string `json:"ftp_permission_list" yaml:"ftp_permission_list"` + PermissionRead string `json:"ftp_permission_read" yaml:"ftp_permission_read"` + PermissionWrite string `json:"ftp_permission_write" yaml:"ftp_permission_write"` + + VirusClamAV string `json:"ftp_virus_clamav" yaml:"ftp_virus_clamav"` + InProgress string `json:"in_progress" yaml:"in_progress"` +} + +// FTPUserList is the typed payload of get_ftpusers; satisfies +// cli.Tabular. +type FTPUserList []FTPUser + +// Client groups the read endpoints scoped to FTP users: +// get_ftpusers (list and singular). +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_ftpusers without parameters and decodes the response +// into an FTPUserList covering every FTP user visible to the login. +func (c *Client) List(ctx context.Context) (FTPUserList, error) { + resp, err := c.API.Call(ctx, "get_ftpusers", nil) + if err != nil { + return nil, err + } + list, err := DecodeFTPUsers(resp.Body.ReturnInfo) + if err != nil { + return nil, fmt.Errorf("ftpuser: get_ftpusers: %w", err) + } + return list, nil +} + +// Get calls get_ftpusers with an ftp_login filter and returns the +// single matching FTPUser. 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) (FTPUser, error) { + if login == "" { + return FTPUser{}, fmt.Errorf("ftpuser: login is required") + } + resp, err := c.API.Call(ctx, "get_ftpusers", map[string]any{"ftp_login": login}) + if err != nil { + return FTPUser{}, err + } + list, err := DecodeFTPUsers(resp.Body.ReturnInfo) + if err != nil { + return FTPUser{}, fmt.Errorf("ftpuser: get_ftpusers: %w", err) + } + if len(list) == 0 { + return FTPUser{}, fmt.Errorf("ftpuser: %q not found", login) + } + return list[0], nil +} + +// DecodeFTPUsers maps the ReturnInfo of a get_ftpusers response (an +// Array of Maps) into the typed FTPUserList. +func DecodeFTPUsers(returnInfo soap.Value) (FTPUserList, error) { + if returnInfo.Kind != soap.KindArray { + return nil, fmt.Errorf("ftpuser: expected ReturnInfo array, got kind %d", returnInfo.Kind) + } + out := make(FTPUserList, 0, len(returnInfo.Array)) + for i, item := range returnInfo.Array { + if item.Kind != soap.KindMap { + return nil, fmt.Errorf("ftpuser: ReturnInfo[%d] is not a Map", i) + } + out = append(out, FTPUser{ + Login: getString(item, "ftp_login"), + Password: getString(item, "ftp_password"), + Passwort: getString(item, "ftp_passwort"), + Path: getString(item, "ftp_path"), + Comment: getString(item, "ftp_comment"), + IsMainUser: getString(item, "ftp_is_main_user"), + PermissionList: getString(item, "ftp_permission_list"), + PermissionRead: getString(item, "ftp_permission_read"), + PermissionWrite: getString(item, "ftp_permission_write"), + VirusClamAV: getString(item, "ftp_virus_clamav"), + InProgress: getString(item, "in_progress"), + }) + } + return out, nil +} + +func getString(m soap.Value, key string) string { + v, ok := m.Get(key) + if !ok { + return "" + } + return v.AsString() +} + +// TableHeaders returns the columns used by --output=table for +// FTPUserList. +func (FTPUserList) TableHeaders() []string { + return []string{"LOGIN", "PATH", "COMMENT", "MAIN", "R", "W", "L", "CLAMAV", "IN_PROGRESS"} +} + +// TableRows emits one row per FTPUser entry. The R/W/L columns mirror +// the KAS UI layout for the three permission flags +// (read/write/list-only) so a quick scan shows what each account can do. +func (l FTPUserList) TableRows() [][]string { + rows := make([][]string, 0, len(l)) + for _, u := range l { + rows = append(rows, []string{ + u.Login, + u.Path, + u.Comment, + u.IsMainUser, + u.PermissionRead, + u.PermissionWrite, + u.PermissionList, + u.VirusClamAV, + u.InProgress, + }) + } + return rows +} + +// TableHeaders for the singular FTPUser view: a key/value layout. +func (FTPUser) TableHeaders() []string { + return []string{"FIELD", "VALUE"} +} + +// TableRows emits the scalar fields. ftp_password / ftp_passwort are +// intentionally omitted — consumers that need them should use +// --output=json|yaml. +func (u FTPUser) TableRows() [][]string { + return [][]string{ + {"ftp_login", u.Login}, + {"ftp_path", u.Path}, + {"ftp_comment", u.Comment}, + {"ftp_is_main_user", u.IsMainUser}, + {"ftp_permission_read", u.PermissionRead}, + {"ftp_permission_write", u.PermissionWrite}, + {"ftp_permission_list", u.PermissionList}, + {"ftp_virus_clamav", u.VirusClamAV}, + {"in_progress", u.InProgress}, + } +} diff --git a/internal/ftpuser/ftpuser_test.go b/internal/ftpuser/ftpuser_test.go new file mode 100644 index 0000000..d61db73 --- /dev/null +++ b/internal/ftpuser/ftpuser_test.go @@ -0,0 +1,232 @@ +package ftpuser_test + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/chmmou/kasapi-cli/internal/ftpuser" + "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", "ftpuser", 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 TestDecodeFTPUsers(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_ftpusers_response_success.xml") + got, err := ftpuser.DecodeFTPUsers(resp.Body.ReturnInfo) + if err != nil { + t.Fatalf("DecodeFTPUsers: %v", err) + } + if len(got) != 5 { + t.Fatalf("len = %d, want 5", len(got)) + } + main := got[0] + if main.Login != "w0000000" { + t.Errorf("Login = %q, want w0000000", main.Login) + } + if main.IsMainUser != "Y" { + t.Errorf("IsMainUser = %q, want Y", main.IsMainUser) + } + listOnly := got[1] + if listOnly.Login != "f0000001" { + t.Errorf("Login = %q, want f0000001", listOnly.Login) + } + if listOnly.Comment != "FTP User List Only" { + t.Errorf("Comment = %q", listOnly.Comment) + } + if listOnly.PermissionList != "Y" || listOnly.PermissionRead != "N" || listOnly.PermissionWrite != "N" { + t.Errorf("permissions = L=%s R=%s W=%s, want L=Y R=N W=N", + listOnly.PermissionList, listOnly.PermissionRead, listOnly.PermissionWrite) + } +} + +func TestDecodeFTPUserSingular(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_ftpuser_response_success.xml") + got, err := ftpuser.DecodeFTPUsers(resp.Body.ReturnInfo) + if err != nil { + t.Fatalf("DecodeFTPUsers: %v", err) + } + if len(got) != 1 { + t.Fatalf("len = %d, want 1", len(got)) + } + u := got[0] + if u.Login != "f0000001" { + t.Errorf("Login = %q, want f0000001", u.Login) + } + if u.Path != "/ftp/path/" { + t.Errorf("Path = %q", u.Path) + } + if u.VirusClamAV != "Y" { + t.Errorf("VirusClamAV = %q, want Y", u.VirusClamAV) + } + // The legacy ftp_passwort key must be preserved alongside ftp_password. + if u.Password == "" || u.Passwort == "" { + t.Errorf("expected both ftp_password and ftp_passwort populated, got %q / %q", + u.Password, u.Passwort) + } +} + +func TestDecodeFTPUsersEmptyList(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_ftpuser_response_success_empty_list.xml") + got, err := ftpuser.DecodeFTPUsers(resp.Body.ReturnInfo) + if err != nil { + t.Fatalf("DecodeFTPUsers: %v", err) + } + if len(got) != 0 { + t.Errorf("len = %d, want 0", len(got)) + } +} + +func TestClientList(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_ftpusers_response_success.xml") + fc := &fakeCaller{resp: resp} + list, err := ftpuser.NewClient(fc).List(context.Background()) + if err != nil { + t.Fatalf("List: %v", err) + } + if fc.gotAction != "get_ftpusers" { + t.Errorf("action = %q, want get_ftpusers", fc.gotAction) + } + if fc.gotParams != nil { + t.Errorf("params = %v, want nil", fc.gotParams) + } + if len(list) != 5 { + t.Errorf("len = %d, want 5", len(list)) + } +} + +func TestClientGet(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_ftpuser_response_success.xml") + fc := &fakeCaller{resp: resp} + u, err := ftpuser.NewClient(fc).Get(context.Background(), "f0000001") + if err != nil { + t.Fatalf("Get: %v", err) + } + if fc.gotAction != "get_ftpusers" { + t.Errorf("action = %q, want get_ftpusers", fc.gotAction) + } + if got, _ := fc.gotParams["ftp_login"].(string); got != "f0000001" { + t.Errorf("params[ftp_login] = %v, want f0000001", fc.gotParams["ftp_login"]) + } + if u.Login != "f0000001" { + t.Errorf("Login = %q, want f0000001", u.Login) + } +} + +func TestClientGetEmptyLogin(t *testing.T) { + t.Parallel() + c := ftpuser.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() + resp := decodeFixture(t, "get_ftpuser_response_success_empty_list.xml") + c := ftpuser.NewClient(&fakeCaller{resp: resp}) + if _, err := c.Get(context.Background(), "missing"); err == nil { + t.Errorf("Get on empty result err = nil, want not-found") + } +} + +func TestClientPropagatesError(t *testing.T) { + t.Parallel() + want := errors.New("boom") + c := ftpuser.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(), "f0000001"); !errors.Is(err, want) { + t.Errorf("Get err = %v, want %v wrapped", err, want) + } +} + +func TestFTPUserListTabular(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_ftpusers_response_success.xml") + list, _ := ftpuser.DecodeFTPUsers(resp.Body.ReturnInfo) + headers := list.TableHeaders() + if headers[0] != "LOGIN" { + t.Errorf("headers[0] = %q, want LOGIN", headers[0]) + } + rows := list.TableRows() + if len(rows) != 5 { + t.Fatalf("rows = %d, want 5", len(rows)) + } + if rows[0][0] != "w0000000" { + t.Errorf("rows[0][0] = %q, want w0000000", rows[0][0]) + } +} + +func TestFTPUserTabular(t *testing.T) { + t.Parallel() + resp := decodeFixture(t, "get_ftpuser_response_success.xml") + list, _ := ftpuser.DecodeFTPUsers(resp.Body.ReturnInfo) + if len(list) != 1 { + t.Fatalf("len = %d, want 1", len(list)) + } + u := list[0] + headers := u.TableHeaders() + if headers[0] != "FIELD" || headers[1] != "VALUE" { + t.Errorf("headers = %v, want [FIELD VALUE]", headers) + } + rows := u.TableRows() + if rows[0][0] != "ftp_login" || rows[0][1] != "f0000001" { + t.Errorf("rows[0] = %v, want [ftp_login f0000001]", rows[0]) + } +}