From c2508a32eddd635d859e94468c2033ea10be32dd Mon Sep 17 00:00:00 2001 From: Alexander Saal Date: Wed, 20 May 2026 20:51:48 +0200 Subject: [PATCH] =?UTF-8?q?feat(database):=20write=20endpoints=20=E2=80=94?= =?UTF-8?q?=20add=20/=20update=20/=20delete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire add_database / update_database / delete_database via the shared kaswrite seam. update and delete are gated by the #109 confirmation prompt; add is reversible and not prompted. All three honour --dry-run (#132) and emit a #131 audit record; the password is redacted in both. update sends only the explicitly-set flags (keyed on cobra Changed). add_database takes no database_login — KAS generates it and the command prints it on success. The password key splits between actions: --password maps to database_password on add and to database_new_password on update (the same _new_password split the ftpuser/sambauser slices carry). delete_database's confirmation prompt uses the louder verb "permanently delete" because the action drops the database AND every row it contains — the loudest data-loss surface of the v0.2.0 write phase. The read path was extended to decode the in_progress flag the KAS API surfaces on every get_databases entry (new IN_PROGRESS column on the list view; appended to the singular detail when present). Existing read-side tests now pin against the refreshed d0123460 fixture snapshot. Closes #122 --- CHANGELOG.md | 32 +++ docs/cli/kasapi-cli.md | 2 +- docs/cli/kasapi-cli_databases.md | 5 +- docs/cli/kasapi-cli_databases_add.md | 39 ++++ docs/cli/kasapi-cli_databases_delete.md | 36 ++++ docs/cli/kasapi-cli_databases_get.md | 2 +- docs/cli/kasapi-cli_databases_list.md | 2 +- docs/cli/kasapi-cli_databases_update.md | 39 ++++ docs/usage/destructive-writes.md | 17 ++ internal/cli/databases.go | 161 +++++++++++++- internal/cli/databases_test.go | 183 +++++++++++++++- internal/database/database.go | 43 ++-- internal/database/database_test.go | 53 ++--- internal/database/fault_test.go | 20 ++ internal/database/write.go | 137 ++++++++++++ internal/database/write_test.go | 197 ++++++++++++++++++ testdata/database/add_database_request.xml | 2 +- ...esponse_failed_account_is_dummyaccount.xml | 11 + ...d_cant_connect_to_mysql_on_this_server.xml | 11 + ...onse_failed_couldnt_get_kas_ressources.xml | 11 + ...atabase_allowed_hosts_syntax_incorrect.xml | 11 + ...iled_database_comment_syntax_incorrect.xml | 11 + ...e_response_failed_max_database_reached.xml | 11 + ...base_response_failed_missing_parameter.xml | 11 + ...esponse_failed_no_mysql_on_this_server.xml | 11 + ...ponse_failed_password_syntax_incorrect.xml | 11 + .../add_database_response_success.xml | 8 +- ...d_cant_connect_to_mysql_on_this_server.xml | 11 + ...sponse_failed_database_login_not_found.xml | 11 + ...e_database_response_failed_in_progress.xml | 11 + ...base_response_failed_missing_parameter.xml | 11 + ...esponse_failed_no_mysql_on_this_server.xml | 11 + testdata/database/get_database_request.xml | 2 +- .../get_database_response_success.xml | 16 +- .../get_databases_response_success.xml | 74 +++++-- testdata/database/update_database_request.xml | 4 +- .../update_database_response_success.xml | 8 +- 37 files changed, 1159 insertions(+), 77 deletions(-) create mode 100644 docs/cli/kasapi-cli_databases_add.md create mode 100644 docs/cli/kasapi-cli_databases_delete.md create mode 100644 docs/cli/kasapi-cli_databases_update.md create mode 100644 internal/database/write.go create mode 100644 internal/database/write_test.go create mode 100644 testdata/database/add_database_response_failed_account_is_dummyaccount.xml create mode 100644 testdata/database/add_database_response_failed_cant_connect_to_mysql_on_this_server.xml create mode 100644 testdata/database/add_database_response_failed_couldnt_get_kas_ressources.xml create mode 100644 testdata/database/add_database_response_failed_database_allowed_hosts_syntax_incorrect.xml create mode 100644 testdata/database/add_database_response_failed_database_comment_syntax_incorrect.xml create mode 100644 testdata/database/add_database_response_failed_max_database_reached.xml create mode 100644 testdata/database/add_database_response_failed_missing_parameter.xml create mode 100644 testdata/database/add_database_response_failed_no_mysql_on_this_server.xml create mode 100644 testdata/database/add_database_response_failed_password_syntax_incorrect.xml create mode 100644 testdata/database/delete_database_response_failed_cant_connect_to_mysql_on_this_server.xml create mode 100644 testdata/database/delete_database_response_failed_database_login_not_found.xml create mode 100644 testdata/database/delete_database_response_failed_in_progress.xml create mode 100644 testdata/database/delete_database_response_failed_missing_parameter.xml create mode 100644 testdata/database/delete_database_response_failed_no_mysql_on_this_server.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index b209d86..ef644ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Database write endpoints (#122, #13 write slice): + `kasapi-cli databases add --password --comment + --allowed-hosts `, + `… update [flags]` and + `… delete ` wire `add_database` / + `update_database` / `delete_database`. `update` and `delete` are + gated by the #109 confirmation prompt; `add` is reversible and not + prompted. All three honour `--dry-run` (#132) and emit a #131 audit + record; the password is redacted in both. `update` sends only the + explicitly-set flags (keyed on cobra `Changed`), so an empty value + is a deliberate "clear". `add_database` takes no `database_login` + — KAS generates it (the login equals the database name on creation, + e.g. `d0123460`) and echoes it in `ReturnInfo`, which the command + prints. The password key is split between actions: `--password` + maps to `database_password` on `add` and to `database_new_password` + on `update` (the same `_new_password` split the ftpuser/sambauser + slices carry). `delete_database`'s confirmation prompt uses the + louder verb "permanently delete" because the action drops the + database and every row it contains — the loudest data-loss surface + of the v0.2.0 write phase. + +### Changed + +- `kasapi-cli databases list`/`databases get` now decode the + `in_progress` flag the KAS API surfaces on every `get_databases` + entry. The list view exposes a new `IN_PROGRESS` column, and the + singular detail view appends an `in_progress` row when present. + Read-test fixtures were captured against an account with a newly- + created database `d0123460`; the existing decode/tabular tests now + pin against that snapshot rather than the previous `d0123450`-based + one. + - DDNS-user write endpoints (#121, #13 write slice): `kasapi-cli ddnsusers add --password --zone --label --target-ip --comment [--dual-stack]`, diff --git a/docs/cli/kasapi-cli.md b/docs/cli/kasapi-cli.md index 2c6fdf2..e06c599 100644 --- a/docs/cli/kasapi-cli.md +++ b/docs/cli/kasapi-cli.md @@ -35,7 +35,7 @@ kasapi-cli [flags] * [kasapi-cli completion](kasapi-cli_completion.md) - Generate the autocompletion script for the specified shell * [kasapi-cli config](kasapi-cli_config.md) - Inspect and bootstrap the kasapi-cli configuration * [kasapi-cli cronjobs](kasapi-cli_cronjobs.md) - Inspect and manage cronjobs (get/add/update/delete_cronjob) -* [kasapi-cli databases](kasapi-cli_databases.md) - Inspect databases visible to the login (get_databases) +* [kasapi-cli databases](kasapi-cli_databases.md) - Inspect and manage databases (get/add/update/delete_database) * [kasapi-cli ddnsusers](kasapi-cli_ddnsusers.md) - Inspect and manage DDNS users (get/add/update/delete_ddnsuser) * [kasapi-cli directoryprotection](kasapi-cli_directoryprotection.md) - Inspect directory (htaccess) protections (get_directoryprotection) * [kasapi-cli dns](kasapi-cli_dns.md) - Inspect DNS records for a zone diff --git a/docs/cli/kasapi-cli_databases.md b/docs/cli/kasapi-cli_databases.md index 73de89d..e938226 100644 --- a/docs/cli/kasapi-cli_databases.md +++ b/docs/cli/kasapi-cli_databases.md @@ -1,6 +1,6 @@ ## kasapi-cli databases -Inspect databases visible to the login (get_databases) +Inspect and manage databases (get/add/update/delete_database) ### Options @@ -29,6 +29,9 @@ Inspect databases visible to the login (get_databases) ### SEE ALSO * [kasapi-cli](kasapi-cli.md) - Command-line client for the All-Inkl KAS API +* [kasapi-cli databases add](kasapi-cli_databases_add.md) - Create a database (add_database; the login is generated by KAS) +* [kasapi-cli databases delete](kasapi-cli_databases_delete.md) - Permanently delete a database and all data in it (delete_database) * [kasapi-cli databases get](kasapi-cli_databases_get.md) - Show details for a single database (get_databases with database_login) * [kasapi-cli databases list](kasapi-cli_databases_list.md) - List all databases (get_databases, no filter) +* [kasapi-cli databases update](kasapi-cli_databases_update.md) - Replace mutable fields of a database (update_database) diff --git a/docs/cli/kasapi-cli_databases_add.md b/docs/cli/kasapi-cli_databases_add.md new file mode 100644 index 0000000..9fd274b --- /dev/null +++ b/docs/cli/kasapi-cli_databases_add.md @@ -0,0 +1,39 @@ +## kasapi-cli databases add + +Create a database (add_database; the login is generated by KAS) + +``` +kasapi-cli databases add --password --comment --allowed-hosts [flags] +``` + +### Options + +``` + --allowed-hosts string comma-separated list of hosts allowed to connect (required for add; e.g. "localhost, 192.168.100.10, 192.168.100.10/32") + --comment string user comment / label (required for add) + -h, --help help for add + --password string database password (required for add; new password for update) +``` + +### Options inherited from parent commands + +``` + --audit-log string append a JSON-Lines audit record for each write action to this file (also KAS_AUDIT_LOG); a logfmt line always goes to stderr regardless + --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) + --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. + --config string path to the kasapi-cli config file (overrides the default location) + --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --login string KAS login (overrides config and KAS_LOGIN) + --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. + -o, --output string output format: json|yaml|table (default table) + --profile string profile to select from the config file (overrides default_profile) + --session-lifetime int session_lifetime in seconds passed to KasAuth (1..30000); 0 keeps the server default. Requires auth_type=session. + --session-update-lifetime string session_update_lifetime passed to KasAuth ('Y' = sliding window, 'N' = fixed). Empty omits the parameter. Requires auth_type=session. + -v, --verbose enable verbose logging on stderr + -y, --yes skip confirmation prompts on destructive operations +``` + +### SEE ALSO + +* [kasapi-cli databases](kasapi-cli_databases.md) - Inspect and manage databases (get/add/update/delete_database) + diff --git a/docs/cli/kasapi-cli_databases_delete.md b/docs/cli/kasapi-cli_databases_delete.md new file mode 100644 index 0000000..a0bd5d1 --- /dev/null +++ b/docs/cli/kasapi-cli_databases_delete.md @@ -0,0 +1,36 @@ +## kasapi-cli databases delete + +Permanently delete a database and all data in it (delete_database) + +``` +kasapi-cli databases delete [flags] +``` + +### Options + +``` + -h, --help help for delete +``` + +### Options inherited from parent commands + +``` + --audit-log string append a JSON-Lines audit record for each write action to this file (also KAS_AUDIT_LOG); a logfmt line always goes to stderr regardless + --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) + --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. + --config string path to the kasapi-cli config file (overrides the default location) + --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --login string KAS login (overrides config and KAS_LOGIN) + --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. + -o, --output string output format: json|yaml|table (default table) + --profile string profile to select from the config file (overrides default_profile) + --session-lifetime int session_lifetime in seconds passed to KasAuth (1..30000); 0 keeps the server default. Requires auth_type=session. + --session-update-lifetime string session_update_lifetime passed to KasAuth ('Y' = sliding window, 'N' = fixed). Empty omits the parameter. Requires auth_type=session. + -v, --verbose enable verbose logging on stderr + -y, --yes skip confirmation prompts on destructive operations +``` + +### SEE ALSO + +* [kasapi-cli databases](kasapi-cli_databases.md) - Inspect and manage databases (get/add/update/delete_database) + diff --git a/docs/cli/kasapi-cli_databases_get.md b/docs/cli/kasapi-cli_databases_get.md index d5c93ac..b4c3cbe 100644 --- a/docs/cli/kasapi-cli_databases_get.md +++ b/docs/cli/kasapi-cli_databases_get.md @@ -32,5 +32,5 @@ kasapi-cli databases get [flags] ### SEE ALSO -* [kasapi-cli databases](kasapi-cli_databases.md) - Inspect databases visible to the login (get_databases) +* [kasapi-cli databases](kasapi-cli_databases.md) - Inspect and manage databases (get/add/update/delete_database) diff --git a/docs/cli/kasapi-cli_databases_list.md b/docs/cli/kasapi-cli_databases_list.md index a3028f9..6df5ed6 100644 --- a/docs/cli/kasapi-cli_databases_list.md +++ b/docs/cli/kasapi-cli_databases_list.md @@ -32,5 +32,5 @@ kasapi-cli databases list [flags] ### SEE ALSO -* [kasapi-cli databases](kasapi-cli_databases.md) - Inspect databases visible to the login (get_databases) +* [kasapi-cli databases](kasapi-cli_databases.md) - Inspect and manage databases (get/add/update/delete_database) diff --git a/docs/cli/kasapi-cli_databases_update.md b/docs/cli/kasapi-cli_databases_update.md new file mode 100644 index 0000000..369ff79 --- /dev/null +++ b/docs/cli/kasapi-cli_databases_update.md @@ -0,0 +1,39 @@ +## kasapi-cli databases update + +Replace mutable fields of a database (update_database) + +``` +kasapi-cli databases update [password/comment/allowed-hosts flags] [flags] +``` + +### Options + +``` + --allowed-hosts string comma-separated list of hosts allowed to connect (required for add; e.g. "localhost, 192.168.100.10, 192.168.100.10/32") + --comment string user comment / label (required for add) + -h, --help help for update + --password string database password (required for add; new password for update) +``` + +### Options inherited from parent commands + +``` + --audit-log string append a JSON-Lines audit record for each write action to this file (also KAS_AUDIT_LOG); a logfmt line always goes to stderr regardless + --auth-data string KAS auth data (overrides config and KAS_AUTHDATA) + --auth-type string KAS auth strategy: 'plain' = send password on each KasApi call (no KasAuth, no 2FA support); 'session' = bootstrap via KasAuth and reuse the credential token. Overrides config and KAS_AUTHTYPE. + --config string path to the kasapi-cli config file (overrides the default location) + --dry-run preview a destructive command's KAS request (action + redacted parameters) and exit 0 without dispatching or prompting; honours --output + --login string KAS login (overrides config and KAS_LOGIN) + --otp string 2FA one-time PIN — sent to KasAuth as session_2fa during the credential-token bootstrap. Requires auth_type=session; the KAS API does not document 2FA on direct kas_auth_type=plain calls. + -o, --output string output format: json|yaml|table (default table) + --profile string profile to select from the config file (overrides default_profile) + --session-lifetime int session_lifetime in seconds passed to KasAuth (1..30000); 0 keeps the server default. Requires auth_type=session. + --session-update-lifetime string session_update_lifetime passed to KasAuth ('Y' = sliding window, 'N' = fixed). Empty omits the parameter. Requires auth_type=session. + -v, --verbose enable verbose logging on stderr + -y, --yes skip confirmation prompts on destructive operations +``` + +### SEE ALSO + +* [kasapi-cli databases](kasapi-cli_databases.md) - Inspect and manage databases (get/add/update/delete_database) + diff --git a/docs/usage/destructive-writes.md b/docs/usage/destructive-writes.md index bf38e32..577ebbe 100644 --- a/docs/usage/destructive-writes.md +++ b/docs/usage/destructive-writes.md @@ -58,6 +58,23 @@ prints it on success. The `--password` flag is redacted in the `--dry-run` preview and the audit record for both `add` and `update` (it maps to `samba_password` on add, `samba_new_password` on update). +[`databases`](https://github.com/chmmou/kasapi-cli/issues/122) `add` / +`update` / `delete` wire the same contract with one important +loudness adjustment: `delete_database`'s confirmation prompt uses the +verb `"permanently delete"` rather than the bare `"delete"` every +other slice uses, because dropping a database also drops every row +it contains — the loudest data-loss surface of the v0.2.0 write +phase. `update` and `delete` are gated; `add` is reversible and not +prompted. `update` is gated because every field it sets is replaced +wholesale (the confirmation prompt phrases this as replacing the +database's *settings*) and `update` sends only the explicitly-changed +flags. `add_database` takes no login — KAS generates it (the login +equals the database name on creation, e.g. `d0123460`) and the +command prints it on success. The `--password` flag is redacted in +the `--dry-run` preview and the audit record for both `add` and +`update` (it maps to `database_password` on add, +`database_new_password` on update). + [`ddnsusers`](https://github.com/chmmou/kasapi-cli/issues/121) `add` / `update` / `delete` wire the same contract with the same policy: `update` and `delete` are gated; `add` is reversible and not prompted. diff --git a/internal/cli/databases.go b/internal/cli/databases.go index 3b226c4..a06b5be 100644 --- a/internal/cli/databases.go +++ b/internal/cli/databases.go @@ -2,6 +2,7 @@ package cli import ( "context" + "fmt" "github.com/spf13/cobra" @@ -10,16 +11,25 @@ import ( ) // NewDatabasesCmd returns the "kasapi-cli databases" subcommand tree: -// list (get_databases, no filter) and get -// (get_databases with a database_login filter). +// list/get (get_databases, list and singular) plus the add / update / +// delete write endpoints (add_database / update_database / +// delete_database). update and delete are gated by the #109 +// confirmation prompt; add is reversible and not prompted. +// +// delete_database drops the database and all data it holds — the +// confirmation prompt uses the verb "permanently delete" so the user +// sees that the action is data-loss, not just metadata removal. func NewDatabasesCmd(opts *RootOptions) *cobra.Command { cmd := &cobra.Command{ Use: "databases", - Short: "Inspect databases visible to the login (get_databases)", + Short: "Inspect and manage databases (get/add/update/delete_database)", } cmd.AddCommand( newDatabasesListCmd(opts), newDatabasesGetCmd(opts), + newDatabasesAddCmd(opts), + newDatabasesUpdateCmd(opts), + newDatabasesDeleteCmd(opts), ) return cmd } @@ -45,3 +55,148 @@ func newDatabasesGetCmd(opts *RootOptions) *cobra.Command { }), } } + +// databaseWriteFlags binds the shared add_database / update_database +// request fields to a command. The same flag set serves both: add +// reads every value, update sends only the flags the user explicitly +// changed (see databaseChangedFields). The password flag maps to a +// different KAS key per action (add_database: database_password, +// update_database: database_new_password) — see spec() and +// databaseChangedFields. +type databaseWriteFlags struct { + password string + comment string + allowedHosts string +} + +func (f *databaseWriteFlags) bind(cmd *cobra.Command) { + fl := cmd.Flags() + fl.StringVar(&f.password, "password", "", "database password (required for add; new password for update)") + fl.StringVar(&f.comment, "comment", "", "user comment / label (required for add)") + fl.StringVar(&f.allowedHosts, "allowed-hosts", "", "comma-separated list of hosts allowed to connect (required for add; e.g. \"localhost, 192.168.100.10, 192.168.100.10/32\")") +} + +func (f *databaseWriteFlags) spec() database.Spec { + return database.Spec{ + Password: f.password, + Comment: f.comment, + AllowedHosts: f.allowedHosts, + } +} + +func newDatabasesAddCmd(opts *RootOptions) *cobra.Command { + f := &databaseWriteFlags{} + cmd := &cobra.Command{ + Use: "add --password --comment --allowed-hosts ", + Short: "Create a database (add_database; the login is generated by KAS)", + Args: cobra.NoArgs, + RunE: runWriteE(opts, func([]string) (writeSpec, error) { + if f.password == "" { + return writeSpec{}, fmt.Errorf("--password is required") + } + if f.comment == "" { + return writeSpec{}, fmt.Errorf("--comment is required") + } + if f.allowedHosts == "" { + return writeSpec{}, fmt.Errorf("--allowed-hosts is required") + } + s := f.spec() + return writeSpec{ + action: "add_database", + destructive: false, + confirm: ConfirmAction{Verb: "create", Resource: "database", ID: f.comment}, + params: database.AddParams(s), + dispatch: func(c *api.Client, ctx context.Context) (string, error) { + login, derr := database.NewClient(c).Add(ctx, s) + if derr != nil { + return "", derr + } + return "created database " + login, nil + }, + }, nil + }), + } + f.bind(cmd) + return cmd +} + +// databaseChangedFields collects only the write flags the user +// explicitly set into the update_database field map (keyed on the +// database.Field* constants). Each field is a wholesale replacement +// and an empty value is a meaningful set, so presence is keyed on +// cobra Changed, not on the value being non-empty — the same pattern +// the ftpuser/sambauser updates use. The password flag maps to +// database_new_password here (update_database's key) rather than the +// add-only database_password. +func databaseChangedFields(cmd *cobra.Command, f *databaseWriteFlags) map[string]string { + fields := map[string]string{} + if cmd.Flags().Changed("password") { + fields[database.FieldNewPassword] = f.password + } + if cmd.Flags().Changed("comment") { + fields[database.FieldComment] = f.comment + } + if cmd.Flags().Changed("allowed-hosts") { + fields[database.FieldAllowedHosts] = f.allowedHosts + } + return fields +} + +func newDatabasesUpdateCmd(opts *RootOptions) *cobra.Command { + f := &databaseWriteFlags{} + cmd := &cobra.Command{ + Use: "update [password/comment/allowed-hosts flags]", + Short: "Replace mutable fields of a database (update_database)", + Args: cobra.ExactArgs(1), + } + cmd.RunE = runWriteE(opts, func(args []string) (writeSpec, error) { + login := args[0] + fields := databaseChangedFields(cmd, f) + if len(fields) == 0 { + return writeSpec{}, fmt.Errorf("at least one field flag (e.g. --password/--comment/--allowed-hosts) is required") + } + return writeSpec{ + action: "update_database", + destructive: true, + confirm: ConfirmAction{Verb: "replace the settings of", Resource: "database", ID: login}, + params: database.UpdateParams(login, fields), + dispatch: func(c *api.Client, ctx context.Context) (string, error) { + if derr := database.NewClient(c).Update(ctx, login, fields); derr != nil { + return "", derr + } + return "updated database " + login, nil + }, + }, nil + }) + f.bind(cmd) + return cmd +} + +func newDatabasesDeleteCmd(opts *RootOptions) *cobra.Command { + return &cobra.Command{ + Use: "delete ", + Short: "Permanently delete a database and all data in it (delete_database)", + Args: cobra.ExactArgs(1), + RunE: runWriteE(opts, func(args []string) (writeSpec, error) { + login := args[0] + return writeSpec{ + action: "delete_database", + destructive: true, + // "permanently delete" is intentionally more emphatic + // than the bare "delete" verb the other slices use: + // delete_database drops the database AND every row it + // contains, which is the loudest data-loss surface of + // the v0.2.0 write phase. The prompt template adds + // "This cannot be undone." regardless. + confirm: ConfirmAction{Verb: "permanently delete", Resource: "database", ID: login}, + params: database.DeleteParams(login), + dispatch: func(c *api.Client, ctx context.Context) (string, error) { + if derr := database.NewClient(c).Delete(ctx, login); derr != nil { + return "", derr + } + return "deleted database " + login, nil + }, + }, nil + }), + } +} diff --git a/internal/cli/databases_test.go b/internal/cli/databases_test.go index 6f12911..2a28b47 100644 --- a/internal/cli/databases_test.go +++ b/internal/cli/databases_test.go @@ -2,6 +2,8 @@ package cli_test import ( "bytes" + "encoding/json" + "errors" "strings" "testing" @@ -21,9 +23,188 @@ func TestDatabasesCmdHelpListsSubcommands(t *testing.T) { t.Fatalf("Execute: %v", err) } out := buf.String() - for _, want := range []string{"list", "get"} { + for _, want := range []string{"list", "get", "add", "update", "delete"} { if !strings.Contains(out, want) { t.Errorf("--help output missing %q\n%s", want, out) } } } + +func TestDatabasesAddRejectsBadInput(t *testing.T) { + t.Parallel() + cases := []struct { + name string + args []string + }{ + {"missing --password", []string{"databases", "add", "--comment", "c", "--allowed-hosts", "localhost"}}, + {"missing --comment", []string{"databases", "add", "--password", "s3cret", "--allowed-hosts", "localhost"}}, + {"missing --allowed-hosts", []string{"databases", "add", "--password", "s3cret", "--comment", "c"}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + root, opts := cli.NewRootCmd() + root.AddCommand(cli.NewDatabasesCmd(opts)) + var buf bytes.Buffer + root.SetOut(&buf) + root.SetErr(&buf) + root.SetArgs(c.args) + err := root.Execute() + if err == nil { + t.Fatalf("Execute %v: want error, got nil", c.args) + } + if cli.CodeFor(err) != cli.ExitUserError { + t.Errorf("exit code = %d, want ExitUserError", cli.CodeFor(err)) + } + }) + } +} + +// The destructive database subcommands (update/delete) must refuse on +// a non-interactive stdin without --yes rather than dispatch +// unconfirmed. +func TestDatabasesDestructiveRefuseNonTTY(t *testing.T) { + t.Parallel() + for _, args := range [][]string{ + {"databases", "delete", "d0123460"}, + {"databases", "update", "d0123460", "--comment", "x"}, + } { + t.Run(args[1], func(t *testing.T) { + t.Parallel() + root, opts := cli.NewRootCmd() + root.AddCommand(cli.NewDatabasesCmd(opts)) + var buf bytes.Buffer + root.SetOut(&buf) + root.SetErr(&buf) + root.SetIn(strings.NewReader("")) + root.SetArgs(append(args, + "--login", "w0000000", "--auth-data", "x", "--auth-type", "plain")) + err := root.Execute() + if err == nil { + t.Fatalf("Execute %v: want refusal error, got nil", args) + } + if !errors.Is(err, cli.ErrConfirmationRequired) { + t.Errorf("err = %v, want ErrConfirmationRequired", err) + } + }) + } +} + +// update_database must send only the explicitly-changed flags (keyed +// on cobra Changed), so an unset field never leaks into the request and +// an empty value passed explicitly is a deliberate set. The password +// flag must map to database_new_password (update's key), never the +// add-only database_password. +func TestDatabasesUpdateDryRunFieldAssembly(t *testing.T) { + t.Parallel() + cases := []struct { + name string + args []string + want map[string]string + absent []string + }{ + { + "comment only", + []string{"databases", "update", "d0123460", "--comment", "Renamed"}, + map[string]string{"database_login": "d0123460", "database_comment": "Renamed"}, + []string{"database_new_password", "database_allowed_hosts"}, + }, + { + // --password must map to update's database_new_password + // key, never the add-only database_password. The value + // itself is redacted in the dry-run/audit preview + // (RedactParams masks *password*), so the mapping is + // proven by the key's presence + absence of + // database_password rather than the cleartext. + "new password maps to database_new_password (redacted)", + []string{"databases", "update", "d0123460", "--password", "n3wpass"}, + map[string]string{"database_login": "d0123460", "database_new_password": ""}, + []string{"database_password", "database_comment"}, + }, + { + "allowed-hosts only", + []string{"databases", "update", "d0123460", "--allowed-hosts", "localhost, 192.168.100.10/24"}, + map[string]string{"database_login": "d0123460", "database_allowed_hosts": "localhost, 192.168.100.10/24"}, + []string{"database_comment", "database_new_password"}, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + root, opts := cli.NewRootCmd() + root.AddCommand(cli.NewDatabasesCmd(opts)) + var out, errb bytes.Buffer + root.SetOut(&out) + root.SetErr(&errb) + args := append(append([]string{}, c.args...), + "--dry-run", "-o", "json", + "--login", "w0", "--auth-data", "x", "--auth-type", "plain") + root.SetArgs(args) + if err := root.Execute(); err != nil { + t.Fatalf("Execute %v: %v", args, err) + } + var got struct { + Action string `json:"action"` + Params map[string]string `json:"params"` + } + if err := json.Unmarshal(out.Bytes(), &got); err != nil { + t.Fatalf("unmarshal preview: %v\nstdout:%s", err, out.String()) + } + if got.Action != "update_database" { + t.Errorf("action = %q, want update_database", got.Action) + } + for k, v := range c.want { + if got.Params[k] != v { + t.Errorf("params[%q] = %q, want %q (full: %v)", k, got.Params[k], v, got.Params) + } + } + for _, k := range c.absent { + if _, ok := got.Params[k]; ok { + t.Errorf("params[%q] present, want absent (full: %v)", k, got.Params) + } + } + }) + } +} + +// delete_database's dry-run preview must address the database by its +// login (target=) and use the delete_database action verbatim, +// so the audit trail can later be reconciled to the resource that was +// dropped. The "louder" prompt verb itself ("permanently delete") is +// pinned by the source-code review anchor (database.go) rather than +// this CLI test because the dry-run preview JSON intentionally omits +// the ConfirmAction shape — only the action / target / params are +// machine-readable. +func TestDatabasesDeleteDryRunTargetsLogin(t *testing.T) { + t.Parallel() + root, opts := cli.NewRootCmd() + root.AddCommand(cli.NewDatabasesCmd(opts)) + var out, errb bytes.Buffer + root.SetOut(&out) + root.SetErr(&errb) + root.SetArgs([]string{ + "databases", "delete", "d0123460", + "--dry-run", "-o", "json", + "--login", "w0", "--auth-data", "x", "--auth-type", "plain", + }) + if err := root.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + var got struct { + Action string `json:"action"` + Target string `json:"target"` + Params map[string]string `json:"params"` + } + if err := json.Unmarshal(out.Bytes(), &got); err != nil { + t.Fatalf("unmarshal preview: %v\nstdout: %s", err, out.String()) + } + if got.Action != "delete_database" { + t.Errorf("action = %q, want delete_database", got.Action) + } + if got.Target != "d0123460" { + t.Errorf("target = %q, want d0123460", got.Target) + } + if got.Params["database_login"] != "d0123460" { + t.Errorf("params[database_login] = %q, want d0123460 (full: %v)", got.Params["database_login"], got.Params) + } +} diff --git a/internal/database/database.go b/internal/database/database.go index f1e3e96..fa532b1 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -17,6 +17,10 @@ type Caller = kasread.Caller // Database is one entry of get_databases. The list and singular views // (the latter being get_databases called with a database_login filter) // return the same Map shape, so a single struct covers both. +// +// in_progress is a pending-async-write flag the KAS API surfaces on +// every database row (typically "FALSE"). It is flagged with omitempty +// for parity with the other modules' optional flags. type Database struct { Name string `json:"database_name" yaml:"database_name"` Login string `json:"database_login" yaml:"database_login"` @@ -24,28 +28,36 @@ type Database struct { Comment string `json:"database_comment" yaml:"database_comment"` AllowedHosts string `json:"database_allowed_hosts" yaml:"database_allowed_hosts"` UsedDatabaseSpace float64 `json:"used_database_space" yaml:"used_database_space"` + InProgress string `json:"in_progress,omitempty" yaml:"in_progress,omitempty"` } // DatabaseList is the typed payload of get_databases; satisfies // cli.Tabular. type DatabaseList []Database -// Client groups the read endpoints scoped to databases: -// get_databases (list and singular). +// Client groups the read endpoints scoped to databases +// (get_databases, list and singular) and the write endpoints +// add_database / update_database / delete_database (see write.go). The +// raw Caller is kept alongside the read helper so the write methods +// can dispatch their own KAS actions through the shared kaswrite seam. type Client struct { lg kasread.ListGet[DatabaseList, Database] + c Caller } // NewClient returns a Client backed by the given Caller. func NewClient(c Caller) *Client { - return &Client{lg: kasread.ListGet[DatabaseList, Database]{ - Caller: c, - Action: "get_databases", - Label: "database", - ArgName: "login", - FilterKey: "database_login", - Decoder: DecodeDatabases, - }} + return &Client{ + lg: kasread.ListGet[DatabaseList, Database]{ + Caller: c, + Action: "get_databases", + Label: "database", + ArgName: "login", + FilterKey: "database_login", + Decoder: DecodeDatabases, + }, + c: c, + } } // List calls get_databases without parameters and decodes the response @@ -71,6 +83,7 @@ func DecodeDatabases(returnInfo soap.Value) (DatabaseList, error) { Comment: item.MapString("database_comment"), AllowedHosts: item.MapString("database_allowed_hosts"), UsedDatabaseSpace: item.MapFloat("used_database_space"), + InProgress: item.MapString("in_progress"), } }) if err != nil { @@ -82,7 +95,7 @@ func DecodeDatabases(returnInfo soap.Value) (DatabaseList, error) { // TableHeaders returns the columns used by --output=table for // DatabaseList. func (DatabaseList) TableHeaders() []string { - return []string{"LOGIN", "NAME", "COMMENT", "ALLOWED_HOSTS", "USED_MB"} + return []string{"LOGIN", "NAME", "COMMENT", "ALLOWED_HOSTS", "USED_MB", "IN_PROGRESS"} } // TableRows emits one row per Database entry. used_database_space is @@ -97,6 +110,7 @@ func (l DatabaseList) TableRows() [][]string { d.Comment, d.AllowedHosts, strconv.FormatFloat(d.UsedDatabaseSpace/1024, 'f', 2, 64), + d.InProgress, }) } return rows @@ -110,12 +124,17 @@ func (Database) TableHeaders() []string { // TableRows emits the scalar fields. database_password is intentionally // omitted — consumers that need it should use --output=json|yaml. +// in_progress only appears when the API actually returned it. func (d Database) TableRows() [][]string { - return [][]string{ + rows := [][]string{ {"database_login", d.Login}, {"database_name", d.Name}, {"database_comment", d.Comment}, {"database_allowed_hosts", d.AllowedHosts}, {"used_database_space", strconv.FormatFloat(d.UsedDatabaseSpace/1024, 'f', 2, 64) + " MB"}, } + if d.InProgress != "" { + rows = append(rows, []string{"in_progress", d.InProgress}) + } + return rows } diff --git a/internal/database/database_test.go b/internal/database/database_test.go index 465d3fa..a5f5e85 100644 --- a/internal/database/database_test.go +++ b/internal/database/database_test.go @@ -17,23 +17,26 @@ func TestDecodeDatabases(t *testing.T) { if err != nil { t.Fatalf("DecodeDatabases: %v", err) } - if len(got) != 4 { - t.Fatalf("len = %d, want 4", len(got)) + if len(got) != 5 { + t.Fatalf("len = %d, want 5", len(got)) } d := got[0] - if d.Login != "d0123450" { - t.Errorf("Login = %q, want d0123450", d.Login) + if d.Login != "d0123456" { + t.Errorf("Login = %q, want d0123456", d.Login) } - if d.Name != "d0123450" { - t.Errorf("Name = %q, want d0123450", d.Name) + if d.Name != "d0123456" { + t.Errorf("Name = %q, want d0123456", d.Name) } - if d.Comment != "my database comment" { - t.Errorf("Comment = %q", d.Comment) + if d.Comment != "my_database_comment" { + t.Errorf("Comment = %q, want my_database_comment", d.Comment) } if d.UsedDatabaseSpace == 0 { t.Errorf("UsedDatabaseSpace = 0, want non-zero from xsd:float") } - // d0123451 is the only entry with a non-empty allowed_hosts in the + if d.InProgress != "FALSE" { + t.Errorf("InProgress = %q, want FALSE", d.InProgress) + } + // d0123457 is the first entry with a non-empty allowed_hosts in the // fixture; verify the empty-string default survives for the others. if got[1].AllowedHosts != "localhost" { t.Errorf("got[1].AllowedHosts = %q, want localhost", got[1].AllowedHosts) @@ -54,11 +57,11 @@ func TestDecodeDatabaseSingular(t *testing.T) { t.Fatalf("len = %d, want 1", len(got)) } d := got[0] - if d.Login != "d0123452" { - t.Errorf("Login = %q, want d0123452", d.Login) + if d.Login != "d0123460" { + t.Errorf("Login = %q, want d0123460", d.Login) } - if d.UsedDatabaseSpace == 0 { - t.Errorf("UsedDatabaseSpace = 0, want non-zero") + if d.InProgress != "FALSE" { + t.Errorf("InProgress = %q, want FALSE", d.InProgress) } } @@ -85,18 +88,18 @@ func TestClientGet(t *testing.T) { t.Parallel() resp := testutil.DecodeFixture(t, "database/get_database_response_success.xml") fc := &testutil.FakeCaller{Resp: resp} - d, err := database.NewClient(fc).Get(context.Background(), "d0123452") + d, err := database.NewClient(fc).Get(context.Background(), "d0123460") if err != nil { t.Fatalf("Get: %v", err) } if fc.GotAction != "get_databases" { t.Errorf("action = %q, want get_databases", fc.GotAction) } - if got, _ := fc.GotParams["database_login"].(string); got != "d0123452" { - t.Errorf("params[database_login] = %v, want d0123452", fc.GotParams["database_login"]) + if got, _ := fc.GotParams["database_login"].(string); got != "d0123460" { + t.Errorf("params[database_login] = %v, want d0123460", fc.GotParams["database_login"]) } - if d.Login != "d0123452" { - t.Errorf("Login = %q, want d0123452", d.Login) + if d.Login != "d0123460" { + t.Errorf("Login = %q, want d0123460", d.Login) } } @@ -124,7 +127,7 @@ func TestClientPropagatesError(t *testing.T) { 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(), "d0123452"); !errors.Is(err, want) { + if _, err := c.Get(context.Background(), "d0123460"); !errors.Is(err, want) { t.Errorf("Get err = %v, want %v wrapped", err, want) } } @@ -138,11 +141,11 @@ func TestDatabaseListTabular(t *testing.T) { t.Errorf("headers[0] = %q, want LOGIN", headers[0]) } rows := list.TableRows() - if len(rows) != 4 { - t.Fatalf("rows = %d, want 4", len(rows)) + if len(rows) != 5 { + t.Fatalf("rows = %d, want 5", len(rows)) } - if rows[0][0] != "d0123450" { - t.Errorf("rows[0][0] = %q, want d0123450", rows[0][0]) + if rows[0][0] != "d0123456" { + t.Errorf("rows[0][0] = %q, want d0123456", rows[0][0]) } } @@ -159,7 +162,7 @@ func TestDatabaseTabular(t *testing.T) { t.Errorf("headers = %v, want [FIELD VALUE]", headers) } rows := d.TableRows() - if rows[0][0] != "database_login" || rows[0][1] != "d0123452" { - t.Errorf("rows[0] = %v, want [database_login d0123452]", rows[0]) + if rows[0][0] != "database_login" || rows[0][1] != "d0123460" { + t.Errorf("rows[0] = %v, want [database_login d0123460]", rows[0]) } } diff --git a/internal/database/fault_test.go b/internal/database/fault_test.go index 67665f1..cad2c03 100644 --- a/internal/database/fault_test.go +++ b/internal/database/fault_test.go @@ -9,7 +9,27 @@ import ( func TestFaultFixturesDecodeToDocumentedCodes(t *testing.T) { t.Parallel() testutil.AssertFaultFixtures(t, "database", map[string]string{ + // add_database faults + "add_database_response_failed_account_is_dummyaccount.xml": "account_is_dummyaccount", + "add_database_response_failed_cant_connect_to_mysql_on_this_server.xml": "cant_connect_to_mysql_on_this_server", + "add_database_response_failed_couldnt_get_kas_ressources.xml": "couldnt_get_kas_ressources", + "add_database_response_failed_database_allowed_hosts_syntax_incorrect.xml": "database_allowed_hosts_syntax_incorrect", + "add_database_response_failed_database_comment_syntax_incorrect.xml": "database_comment_syntax_incorrect", + "add_database_response_failed_max_database_reached.xml": "max_database_reached", + "add_database_response_failed_missing_parameter.xml": "missing_parameter", + "add_database_response_failed_no_mysql_on_this_server.xml": "no_mysql_on_this_server", + "add_database_response_failed_password_syntax_incorrect.xml": "password_syntax_incorrect", + // update_database faults "update_database_response_failed_database_allowed_hosts_syntax_incorrect.xml": "database_allowed_hosts_syntax_incorrect", "update_database_response_failed_database_comment_syntax_incorrect.xml": "database_comment_syntax_incorrect", + "update_database_response_failed_database_login_not_found.xml": "database_login_not_found", + "update_database_response_failed_missing_parameter.xml": "missing_parameter", + "update_database_response_failed_password_syntax_incorrect.xml": "password_syntax_incorrect", + // delete_database faults + "delete_database_response_failed_cant_connect_to_mysql_on_this_server.xml": "cant_connect_to_mysql_on_this_server", + "delete_database_response_failed_database_login_not_found.xml": "database_login_not_found", + "delete_database_response_failed_in_progress.xml": "in_progress", + "delete_database_response_failed_missing_parameter.xml": "missing_parameter", + "delete_database_response_failed_no_mysql_on_this_server.xml": "no_mysql_on_this_server", }) } diff --git a/internal/database/write.go b/internal/database/write.go new file mode 100644 index 0000000..9ed41fe --- /dev/null +++ b/internal/database/write.go @@ -0,0 +1,137 @@ +package database + +import ( + "context" + "errors" + + "github.com/chmmou/kasapi-cli/internal/kaswrite" +) + +// ErrUnexpectedReturnString is the shared canonical post-call-contract +// sentinel, re-exported so errors.Is(err, database.ErrUnexpectedReturnString) +// keeps working and the slice stays self-describing. See +// kaswrite.ErrUnexpectedReturnString for the full contract. +var ErrUnexpectedReturnString = kaswrite.ErrUnexpectedReturnString + +const ( + addAction = "add_database" + updateAction = "update_database" + deleteAction = "delete_database" +) + +// The Field-prefixed constants are the KAS request keys add_database / +// update_database accept besides the database_login identifier; +// AddParams reuses the subset add expects. Each update field is an +// optional wholesale replacement: only the keys the caller explicitly +// sets are sent, so an empty string is a meaningful value rather than +// "leave unchanged". +// +// The password key differs between actions: add_database sets the +// initial password via database_password, update_database replaces it +// via database_new_password. Both keys are kept explicit so neither +// path can silently send the wrong one (the same _new_password split +// the ftpuser/sambauser slices carry). +const ( + FieldLogin = "database_login" + FieldPassword = "database_password" + FieldNewPassword = "database_new_password" + FieldComment = "database_comment" + FieldAllowedHosts = "database_allowed_hosts" +) + +// Spec carries the add_database request fields. Password, comment and +// allowed_hosts are required by the KAS API and validated as non-empty +// before any SOAP call so the CLI can surface a fast validation error. +// The allowed_hosts grammar (comma-separated host names / IPs / CIDR) +// is delegated to the API — a wrong value surfaces as +// database_allowed_hosts_syntax_incorrect. +// +// add_database takes no database_login: the server auto-generates the +// login (always equal to the database name on creation, e.g. +// "d0123460") and echoes it in ReturnInfo, which Add returns. +type Spec struct { + Password string + Comment string + AllowedHosts string +} + +// Add creates a database (add_database) and returns the login the +// server assigns, as echoed in ReturnInfo (e.g. "d0123460"). +// +// Password, comment and allowed_hosts are validated before the SOAP +// call so an obviously incomplete spec fails fast; the remaining syntax +// validation (password_syntax_incorrect, +// database_allowed_hosts_syntax_incorrect, …) is left to the API and +// surfaces verbatim through the Caller. +func (cl *Client) Add(ctx context.Context, s Spec) (string, error) { + if s.Password == "" || s.Comment == "" || s.AllowedHosts == "" { + return "", errors.New("database: add_database requires a non-empty password, comment and allowed_hosts") + } + resp, err := kaswrite.Call(ctx, cl.c, "database", addAction, AddParams(s)) + if err != nil { + return "", err + } + return resp.Body.ReturnInfo.AsString(), nil +} + +// AddParams builds the add_database KAS request parameter map. It is +// the single source of truth for the request shape so the CLI dry-run +// preview / audit record and the dispatched call cannot diverge. No +// database_login is sent — the server generates it. The password is +// sent under database_password; update uses database_new_password +// instead. +func AddParams(s Spec) map[string]any { + return map[string]any{ + FieldPassword: s.Password, + FieldComment: s.Comment, + FieldAllowedHosts: s.AllowedHosts, + } +} + +// Update changes one or more mutable fields of an existing database +// (update_database). fields holds only the keys the caller wants to +// change (use the Field* constants; the password is FieldNewPassword +// here, not FieldPassword); each is applied wholesale. login and at +// least one field are required — update_database with nothing to +// change is rejected before the SOAP call (the API would fault +// nothing_to_do). +func (cl *Client) Update(ctx context.Context, login string, fields map[string]string) error { + if login == "" { + return errors.New("database: update_database requires a non-empty database login") + } + if len(fields) == 0 { + return errors.New("database: update_database requires at least one field to change") + } + _, err := kaswrite.Call(ctx, cl.c, "database", updateAction, UpdateParams(login, fields)) + return err +} + +// UpdateParams builds the update_database KAS request parameter map +// (single source of truth, see AddParams): the database_login +// identifier plus every caller-supplied mutable field verbatim. +func UpdateParams(login string, fields map[string]string) map[string]any { + params := map[string]any{FieldLogin: login} + for k, v := range fields { + params[k] = v + } + return params +} + +// Delete removes a database (delete_database). The action is highly +// destructive — it drops the database and every row in it. The CLI +// gates it behind the #109 confirmation prompt; a SOAP fault (e.g. +// database_login_not_found, in_progress) is surfaced verbatim by the +// Caller so the caller can classify it via the api error helpers. +func (cl *Client) Delete(ctx context.Context, login string) error { + if login == "" { + return errors.New("database: delete_database requires a non-empty database login") + } + _, err := kaswrite.Call(ctx, cl.c, "database", deleteAction, DeleteParams(login)) + return err +} + +// DeleteParams builds the delete_database KAS request parameter map +// (single source of truth, see AddParams). +func DeleteParams(login string) map[string]any { + return map[string]any{FieldLogin: login} +} diff --git a/internal/database/write_test.go b/internal/database/write_test.go new file mode 100644 index 0000000..a6ab0b2 --- /dev/null +++ b/internal/database/write_test.go @@ -0,0 +1,197 @@ +package database_test + +import ( + "context" + "errors" + "testing" + + "github.com/chmmou/kasapi-cli/internal/database" + "github.com/chmmou/kasapi-cli/internal/soap" + "github.com/chmmou/kasapi-cli/internal/testutil" +) + +func sampleSpec() database.Spec { + return database.Spec{ + Password: "s3cret", + Comment: "Test Database for CLI Test", + AllowedHosts: "localhost, 192.168.100.10, 192.168.100.10/32", + } +} + +func TestClientAdd(t *testing.T) { + t.Parallel() + resp := testutil.DecodeFixture(t, "database/add_database_response_success.xml") + fc := &testutil.FakeCaller{Resp: resp} + login, err := database.NewClient(fc).Add(context.Background(), sampleSpec()) + if err != nil { + t.Fatalf("Add: %v", err) + } + if fc.GotAction != "add_database" { + t.Errorf("action = %q, want add_database", fc.GotAction) + } + wantParams := map[string]any{ + "database_password": "s3cret", + "database_comment": "Test Database for CLI Test", + "database_allowed_hosts": "localhost, 192.168.100.10, 192.168.100.10/32", + } + for k, v := range wantParams { + if fc.GotParams[k] != v { + t.Errorf("params[%q] = %v, want %v (full: %v)", k, fc.GotParams[k], v, fc.GotParams) + } + } + // add_database must not carry a database_login (the server generates + // it) nor the update-only database_new_password key. + for _, k := range []string{"database_login", "database_new_password"} { + if _, ok := fc.GotParams[k]; ok { + t.Errorf("add_database must not send %q: %v", k, fc.GotParams) + } + } + if login != "d0123460" { + t.Errorf("returned login = %q, want d0123460 (fixture ReturnInfo)", login) + } +} + +func TestClientUpdate(t *testing.T) { + t.Parallel() + resp := testutil.DecodeFixture(t, "database/update_database_response_success.xml") + fc := &testutil.FakeCaller{Resp: resp} + fields := map[string]string{ + database.FieldNewPassword: "n3wpass", + database.FieldComment: "Test Database for CLI Test Update", + database.FieldAllowedHosts: "localhost, 192.168.100.10, 192.168.100.10/24", + } + if err := database.NewClient(fc).Update(context.Background(), "d0123460", fields); err != nil { + t.Fatalf("Update: %v", err) + } + if fc.GotAction != "update_database" { + t.Errorf("action = %q, want update_database", fc.GotAction) + } + wantParams := map[string]any{ + "database_login": "d0123460", + "database_new_password": "n3wpass", + "database_comment": "Test Database for CLI Test Update", + "database_allowed_hosts": "localhost, 192.168.100.10, 192.168.100.10/24", + } + for k, v := range wantParams { + if fc.GotParams[k] != v { + t.Errorf("params[%q] = %v, want %v (full: %v)", k, fc.GotParams[k], v, fc.GotParams) + } + } + // update_database uses database_new_password — the add-only + // database_password key must not leak through. + if _, ok := fc.GotParams["database_password"]; ok { + t.Errorf("update_database must not send database_password (it uses database_new_password): %v", fc.GotParams) + } +} + +func TestClientDelete(t *testing.T) { + t.Parallel() + resp := testutil.DecodeFixture(t, "database/delete_database_response_success.xml") + fc := &testutil.FakeCaller{Resp: resp} + if err := database.NewClient(fc).Delete(context.Background(), "d0123456"); err != nil { + t.Fatalf("Delete: %v", err) + } + if fc.GotAction != "delete_database" { + t.Errorf("action = %q, want delete_database", fc.GotAction) + } + if fc.GotParams["database_login"] != "d0123456" { + t.Errorf("params = %v", fc.GotParams) + } +} + +func TestWriteValidation(t *testing.T) { + t.Parallel() + c := database.NewClient(&testutil.FakeCaller{}) + ctx := context.Background() + + for _, tc := range []struct { + name string + mut func(*database.Spec) + }{ + {"missing password", func(s *database.Spec) { s.Password = "" }}, + {"missing comment", func(s *database.Spec) { s.Comment = "" }}, + {"missing allowed_hosts", func(s *database.Spec) { s.AllowedHosts = "" }}, + } { + s := sampleSpec() + tc.mut(&s) + if _, err := c.Add(ctx, s); err == nil { + t.Errorf("Add %s: err = nil, want validation error", tc.name) + } + } + if err := c.Update(ctx, "", map[string]string{database.FieldComment: "x"}); err == nil { + t.Error("Update empty login: err = nil, want validation error") + } + if err := c.Update(ctx, "d0123460", nil); err == nil { + t.Error("Update no fields: err = nil, want validation error") + } + if err := c.Delete(ctx, ""); err == nil { + t.Error("Delete empty login: err = nil, want validation error") + } +} + +func TestUnexpectedReturnString(t *testing.T) { + t.Parallel() + resp := &soap.Response{Body: soap.ResponseBody{ReturnString: "FALSE"}} + c := database.NewClient(&testutil.FakeCaller{Resp: resp}) + ctx := context.Background() + if _, err := c.Add(ctx, sampleSpec()); !errors.Is(err, database.ErrUnexpectedReturnString) { + t.Errorf("Add err = %v, want ErrUnexpectedReturnString", err) + } + if err := c.Update(ctx, "d0123460", map[string]string{database.FieldComment: "x"}); !errors.Is(err, database.ErrUnexpectedReturnString) { + t.Errorf("Update err = %v, want ErrUnexpectedReturnString", err) + } + if err := c.Delete(ctx, "d0123460"); !errors.Is(err, database.ErrUnexpectedReturnString) { + t.Errorf("Delete err = %v, want ErrUnexpectedReturnString", err) + } +} + +func TestWritePropagatesError(t *testing.T) { + t.Parallel() + want := errors.New("boom") + c := database.NewClient(&testutil.FakeCaller{Err: want}) + ctx := context.Background() + if _, err := c.Add(ctx, sampleSpec()); !errors.Is(err, want) { + t.Errorf("Add err = %v, want %v", err, want) + } + if err := c.Update(ctx, "d0123460", map[string]string{database.FieldComment: "x"}); !errors.Is(err, want) { + t.Errorf("Update err = %v, want %v", err, want) + } + if err := c.Delete(ctx, "d0123460"); !errors.Is(err, want) { + t.Errorf("Delete err = %v, want %v", err, want) + } +} + +func TestParamBuilders(t *testing.T) { + t.Parallel() + add := database.AddParams(sampleSpec()) + wantAdd := map[string]any{ + "database_password": "s3cret", + "database_comment": "Test Database for CLI Test", + "database_allowed_hosts": "localhost, 192.168.100.10, 192.168.100.10/32", + } + for k, v := range wantAdd { + if add[k] != v { + t.Errorf("AddParams[%q] = %v, want %v", k, add[k], v) + } + } + if len(add) != 3 { + t.Errorf("AddParams has %d keys, want 3 (password/comment/allowed_hosts, no database_login)", len(add)) + } + if _, ok := add["database_login"]; ok { + t.Errorf("AddParams must not contain database_login: %v", add) + } + upd := database.UpdateParams("d0123460", map[string]string{ + database.FieldNewPassword: "n3wpass", + database.FieldComment: "c", + }) + if upd["database_login"] != "d0123460" || upd["database_new_password"] != "n3wpass" || upd["database_comment"] != "c" { + t.Errorf("UpdateParams = %v", upd) + } + if _, ok := upd["database_password"]; ok { + t.Errorf("UpdateParams must not contain database_password (update uses _new_password): %v", upd) + } + del := database.DeleteParams("d0123460") + if len(del) != 1 || del["database_login"] != "d0123460" { + t.Errorf("DeleteParams = %v", del) + } +} diff --git a/testdata/database/add_database_request.xml b/testdata/database/add_database_request.xml index 766f485..a05fe03 100644 --- a/testdata/database/add_database_request.xml +++ b/testdata/database/add_database_request.xml @@ -7,7 +7,7 @@ "KasRequestParams": { "database_password": "REDACTED", "database_comment": "Test Database for CLI Test", - "database_allowed_hosts": "localhost or comma seperated ip list or empty string" + "database_allowed_hosts": "localhost, 192.168.100.10, 192.168.100.10/32" }, "kas_action": "add_database", "kas_auth_data": "REDACTED", diff --git a/testdata/database/add_database_response_failed_account_is_dummyaccount.xml b/testdata/database/add_database_response_failed_account_is_dummyaccount.xml new file mode 100644 index 0000000..f729e91 --- /dev/null +++ b/testdata/database/add_database_response_failed_account_is_dummyaccount.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + account_is_dummyaccount + KasApi + + + + \ No newline at end of file diff --git a/testdata/database/add_database_response_failed_cant_connect_to_mysql_on_this_server.xml b/testdata/database/add_database_response_failed_cant_connect_to_mysql_on_this_server.xml new file mode 100644 index 0000000..9056981 --- /dev/null +++ b/testdata/database/add_database_response_failed_cant_connect_to_mysql_on_this_server.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + cant_connect_to_mysql_on_this_server + KasApi + + + + \ No newline at end of file diff --git a/testdata/database/add_database_response_failed_couldnt_get_kas_ressources.xml b/testdata/database/add_database_response_failed_couldnt_get_kas_ressources.xml new file mode 100644 index 0000000..3d78774 --- /dev/null +++ b/testdata/database/add_database_response_failed_couldnt_get_kas_ressources.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + couldnt_get_kas_ressources + KasApi + + + + \ No newline at end of file diff --git a/testdata/database/add_database_response_failed_database_allowed_hosts_syntax_incorrect.xml b/testdata/database/add_database_response_failed_database_allowed_hosts_syntax_incorrect.xml new file mode 100644 index 0000000..7fbb1c8 --- /dev/null +++ b/testdata/database/add_database_response_failed_database_allowed_hosts_syntax_incorrect.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + database_allowed_hosts_syntax_incorrect + KasApi + + + + \ No newline at end of file diff --git a/testdata/database/add_database_response_failed_database_comment_syntax_incorrect.xml b/testdata/database/add_database_response_failed_database_comment_syntax_incorrect.xml new file mode 100644 index 0000000..692902c --- /dev/null +++ b/testdata/database/add_database_response_failed_database_comment_syntax_incorrect.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + database_comment_syntax_incorrect + KasApi + + + + \ No newline at end of file diff --git a/testdata/database/add_database_response_failed_max_database_reached.xml b/testdata/database/add_database_response_failed_max_database_reached.xml new file mode 100644 index 0000000..878472b --- /dev/null +++ b/testdata/database/add_database_response_failed_max_database_reached.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + max_database_reached + KasApi + + + + \ No newline at end of file diff --git a/testdata/database/add_database_response_failed_missing_parameter.xml b/testdata/database/add_database_response_failed_missing_parameter.xml new file mode 100644 index 0000000..f482074 --- /dev/null +++ b/testdata/database/add_database_response_failed_missing_parameter.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + missing_parameter + KasApi + + + + \ No newline at end of file diff --git a/testdata/database/add_database_response_failed_no_mysql_on_this_server.xml b/testdata/database/add_database_response_failed_no_mysql_on_this_server.xml new file mode 100644 index 0000000..3e34c03 --- /dev/null +++ b/testdata/database/add_database_response_failed_no_mysql_on_this_server.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + no_mysql_on_this_server + KasApi + + + + \ No newline at end of file diff --git a/testdata/database/add_database_response_failed_password_syntax_incorrect.xml b/testdata/database/add_database_response_failed_password_syntax_incorrect.xml new file mode 100644 index 0000000..3ae957b --- /dev/null +++ b/testdata/database/add_database_response_failed_password_syntax_incorrect.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + password_syntax_incorrect + KasApi + + + + \ No newline at end of file diff --git a/testdata/database/add_database_response_success.xml b/testdata/database/add_database_response_success.xml index f12f327..c378c7a 100644 --- a/testdata/database/add_database_response_success.xml +++ b/testdata/database/add_database_response_success.xml @@ -8,7 +8,7 @@ KasRequestTime - 1777413557 + 1779301662 KasRequestType @@ -27,7 +27,7 @@ database_allowed_hosts - + localhost, 192.168.100.10, 192.168.100.10/32 @@ -46,7 +46,7 @@ ReturnInfo - d0123456 + d0123460 Msg @@ -58,7 +58,7 @@ text - The database d0123456 has been created. + The database d0123460 has been created. diff --git a/testdata/database/delete_database_response_failed_cant_connect_to_mysql_on_this_server.xml b/testdata/database/delete_database_response_failed_cant_connect_to_mysql_on_this_server.xml new file mode 100644 index 0000000..9056981 --- /dev/null +++ b/testdata/database/delete_database_response_failed_cant_connect_to_mysql_on_this_server.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + cant_connect_to_mysql_on_this_server + KasApi + + + + \ No newline at end of file diff --git a/testdata/database/delete_database_response_failed_database_login_not_found.xml b/testdata/database/delete_database_response_failed_database_login_not_found.xml new file mode 100644 index 0000000..a75d752 --- /dev/null +++ b/testdata/database/delete_database_response_failed_database_login_not_found.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + database_login_not_found + KasApi + + + + \ No newline at end of file diff --git a/testdata/database/delete_database_response_failed_in_progress.xml b/testdata/database/delete_database_response_failed_in_progress.xml new file mode 100644 index 0000000..50ebb3d --- /dev/null +++ b/testdata/database/delete_database_response_failed_in_progress.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + in_progress + KasApi + + + + \ No newline at end of file diff --git a/testdata/database/delete_database_response_failed_missing_parameter.xml b/testdata/database/delete_database_response_failed_missing_parameter.xml new file mode 100644 index 0000000..f482074 --- /dev/null +++ b/testdata/database/delete_database_response_failed_missing_parameter.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + missing_parameter + KasApi + + + + \ No newline at end of file diff --git a/testdata/database/delete_database_response_failed_no_mysql_on_this_server.xml b/testdata/database/delete_database_response_failed_no_mysql_on_this_server.xml new file mode 100644 index 0000000..3e34c03 --- /dev/null +++ b/testdata/database/delete_database_response_failed_no_mysql_on_this_server.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + no_mysql_on_this_server + KasApi + + + + \ No newline at end of file diff --git a/testdata/database/get_database_request.xml b/testdata/database/get_database_request.xml index d6b385f..1119a13 100644 --- a/testdata/database/get_database_request.xml +++ b/testdata/database/get_database_request.xml @@ -5,7 +5,7 @@ { "KasRequestParams": { - "database_login": "d0123456" + "database_login": "d0123460" }, "kas_action": "get_databases", "kas_auth_data": "REDACTED", diff --git a/testdata/database/get_database_response_success.xml b/testdata/database/get_database_response_success.xml index b14eb34..9a743d7 100644 --- a/testdata/database/get_database_response_success.xml +++ b/testdata/database/get_database_response_success.xml @@ -19,7 +19,7 @@ database_login - d0123452 + d0123460 @@ -42,11 +42,11 @@ database_name - d0123452 + d0123460 database_login - d0123452 + d0123460 database_password @@ -54,15 +54,19 @@ database_comment - my database comment + Test Database for CLI Test Update database_allowed_hosts - + localhost, 192.168.100.10, 192.168.100.10/32 used_database_space - 0.0654296875 + 0 + + + in_progress + FALSE diff --git a/testdata/database/get_databases_response_success.xml b/testdata/database/get_databases_response_success.xml index d16f691..56e5e3f 100644 --- a/testdata/database/get_databases_response_success.xml +++ b/testdata/database/get_databases_response_success.xml @@ -33,15 +33,15 @@ ReturnInfo - + database_name - d0123450 + d0123456 database_login - d0123450 + d0123456 database_password @@ -49,7 +49,7 @@ database_comment - my database comment + my_database_comment database_allowed_hosts @@ -59,15 +59,19 @@ used_database_space 82701.4658203 + + in_progress + FALSE + database_name - d0123451 + d0123457 database_login - d0123451 + d0123457 database_password @@ -75,7 +79,7 @@ database_comment - my database comment + my_database_comment database_allowed_hosts @@ -85,15 +89,19 @@ used_database_space 0.0654296875 + + in_progress + FALSE + database_name - d0123452 + d0123458 database_login - d0123452 + d0123458 database_password @@ -101,7 +109,7 @@ database_comment - my database comment + my_database_comment database_allowed_hosts @@ -111,15 +119,19 @@ used_database_space 0.0654296875 + + in_progress + FALSE + database_name - d0123453 + d0123459 database_login - d0123453 + d0123459 database_password @@ -127,7 +139,7 @@ database_comment - my database comment + my_database_comment database_allowed_hosts @@ -137,6 +149,40 @@ used_database_space 134.278320312 + + in_progress + FALSE + + + + + database_name + d0123460 + + + database_login + d0123460 + + + database_password + REDACTED + + + database_comment + my_database_comment + + + database_allowed_hosts + localhost, 192.168.100.10, 192.168.100.10/24 + + + used_database_space + 0 + + + in_progress + FALSE + @@ -145,4 +191,4 @@ - + \ No newline at end of file diff --git a/testdata/database/update_database_request.xml b/testdata/database/update_database_request.xml index 9b35c2f..db9080a 100644 --- a/testdata/database/update_database_request.xml +++ b/testdata/database/update_database_request.xml @@ -5,10 +5,10 @@ { "KasRequestParams": { - "database_login": "d0123456", + "database_login": "d0123460", "database_new_password": "REDACTED", "database_comment": "Test Database for CLI Test Update", - "database_allowed_hosts": "" + "database_allowed_hosts": "localhost, 192.168.100.10, 192.168.100.10/24" }, "kas_action": "update_database", "kas_auth_data": "REDACTED", diff --git a/testdata/database/update_database_response_success.xml b/testdata/database/update_database_response_success.xml index 8ff3977..43a0ea6 100644 --- a/testdata/database/update_database_response_success.xml +++ b/testdata/database/update_database_response_success.xml @@ -8,7 +8,7 @@ KasRequestTime - 1777415298 + 1779301840 KasRequestType @@ -19,7 +19,7 @@ database_login - d0123456 + d0123460 database_new_password @@ -31,7 +31,7 @@ database_allowed_hosts - + localhost, 192.168.100.10, 192.168.100.10/24 @@ -62,7 +62,7 @@ text - The settings of the database d0123456 have been altered. + The settings of the database d0123460 have been altered.