diff --git a/CHANGELOG.md b/CHANGELOG.md index fd7b853..100e661 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -129,6 +129,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Directory protection write endpoints (#123, #13 write slice): + `kasapi-cli directoryprotection add --password + [--authname ]`, `… update [--password ] + [--authname ]` and `… delete ` wire + `add_directoryprotection` / `update_directoryprotection` / + `delete_directoryprotection`. A protection entry is identified by the + `(path, user)` pair taken as two positional arguments (a single path + can protect several users). `update` and `delete` are gated by the + #109 confirmation prompt — `update` replaces the access password (the + previous one is unrecoverable) and `delete` revokes access, so both + can lock users out; `add` is reversible and not prompted. All three + honour `--dry-run` (#132) and emit a #131 audit record; + `directory_password` is redacted in both sinks. There is **no** + `_new_password` split — `update` sends the replacement under the same + `directory_password` key `add` uses — and `update` sends only the + explicitly-changed `--password`/`--authname` (keyed on cobra + `Changed`), so an omitted password keeps the current one. KAS also + accepts parallel `directory_user`/`directory_password` arrays to + create several protected users in one call (hence the + `directory_user_count_neq_passcount` fault); the captured request + fixtures only exercise the scalar single-user form, and the array + wire-encoding is not captured, so this slice deliberately models one + `(path, user)` protection per call rather than inventing the array + shape. + - Mail standard filter write endpoints (#116, #13 write slice): `kasapi-cli mail filters add --filter [--filter ...]` and `… delete ` wire diff --git a/docs/cli/kasapi-cli.md b/docs/cli/kasapi-cli.md index e06c599..3084403 100644 --- a/docs/cli/kasapi-cli.md +++ b/docs/cli/kasapi-cli.md @@ -37,7 +37,7 @@ kasapi-cli [flags] * [kasapi-cli cronjobs](kasapi-cli_cronjobs.md) - Inspect and manage cronjobs (get/add/update/delete_cronjob) * [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 directoryprotection](kasapi-cli_directoryprotection.md) - Inspect and manage directory (htaccess) protections (get/add/update/delete_directoryprotection) * [kasapi-cli dns](kasapi-cli_dns.md) - Inspect DNS records for a zone * [kasapi-cli domains](kasapi-cli_domains.md) - Inspect domains owned by the authenticated account * [kasapi-cli ftpusers](kasapi-cli_ftpusers.md) - Inspect and manage FTP users (get/add/update/delete_ftpuser) diff --git a/docs/cli/kasapi-cli_directoryprotection.md b/docs/cli/kasapi-cli_directoryprotection.md index 3944f63..96d36e7 100644 --- a/docs/cli/kasapi-cli_directoryprotection.md +++ b/docs/cli/kasapi-cli_directoryprotection.md @@ -1,6 +1,6 @@ ## kasapi-cli directoryprotection -Inspect directory (htaccess) protections (get_directoryprotection) +Inspect and manage directory (htaccess) protections (get/add/update/delete_directoryprotection) ### Options @@ -29,5 +29,8 @@ Inspect directory (htaccess) protections (get_directoryprotection) ### SEE ALSO * [kasapi-cli](kasapi-cli.md) - Command-line client for the All-Inkl KAS API +* [kasapi-cli directoryprotection add](kasapi-cli_directoryprotection_add.md) - Protect a path for a user (add_directoryprotection) +* [kasapi-cli directoryprotection delete](kasapi-cli_directoryprotection_delete.md) - Revoke a user's directory protection on a path (delete_directoryprotection) * [kasapi-cli directoryprotection list](kasapi-cli_directoryprotection_list.md) - List directory protections, optionally filtered by --path +* [kasapi-cli directoryprotection update](kasapi-cli_directoryprotection_update.md) - Replace the password and/or realm of a directory protection (update_directoryprotection) diff --git a/docs/cli/kasapi-cli_directoryprotection_add.md b/docs/cli/kasapi-cli_directoryprotection_add.md new file mode 100644 index 0000000..92124cb --- /dev/null +++ b/docs/cli/kasapi-cli_directoryprotection_add.md @@ -0,0 +1,38 @@ +## kasapi-cli directoryprotection add + +Protect a path for a user (add_directoryprotection) + +``` +kasapi-cli directoryprotection add --password [--authname ] [flags] +``` + +### Options + +``` + --authname string htaccess realm label shown in the browser auth dialog (optional; directory_authname) + -h, --help help for add + --password string access password for the protected user (required) +``` + +### 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 directoryprotection](kasapi-cli_directoryprotection.md) - Inspect and manage directory (htaccess) protections (get/add/update/delete_directoryprotection) + diff --git a/docs/cli/kasapi-cli_directoryprotection_delete.md b/docs/cli/kasapi-cli_directoryprotection_delete.md new file mode 100644 index 0000000..3a85f13 --- /dev/null +++ b/docs/cli/kasapi-cli_directoryprotection_delete.md @@ -0,0 +1,36 @@ +## kasapi-cli directoryprotection delete + +Revoke a user's directory protection on a path (delete_directoryprotection) + +``` +kasapi-cli directoryprotection 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 directoryprotection](kasapi-cli_directoryprotection.md) - Inspect and manage directory (htaccess) protections (get/add/update/delete_directoryprotection) + diff --git a/docs/cli/kasapi-cli_directoryprotection_list.md b/docs/cli/kasapi-cli_directoryprotection_list.md index 3acde13..8fc5a5b 100644 --- a/docs/cli/kasapi-cli_directoryprotection_list.md +++ b/docs/cli/kasapi-cli_directoryprotection_list.md @@ -33,5 +33,5 @@ kasapi-cli directoryprotection list [flags] ### SEE ALSO -* [kasapi-cli directoryprotection](kasapi-cli_directoryprotection.md) - Inspect directory (htaccess) protections (get_directoryprotection) +* [kasapi-cli directoryprotection](kasapi-cli_directoryprotection.md) - Inspect and manage directory (htaccess) protections (get/add/update/delete_directoryprotection) diff --git a/docs/cli/kasapi-cli_directoryprotection_update.md b/docs/cli/kasapi-cli_directoryprotection_update.md new file mode 100644 index 0000000..4896a6e --- /dev/null +++ b/docs/cli/kasapi-cli_directoryprotection_update.md @@ -0,0 +1,38 @@ +## kasapi-cli directoryprotection update + +Replace the password and/or realm of a directory protection (update_directoryprotection) + +``` +kasapi-cli directoryprotection update [--password ] [--authname ] [flags] +``` + +### Options + +``` + --authname string replacement htaccess realm label (directory_authname) + -h, --help help for update + --password string replacement access password (sent as directory_password) +``` + +### 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 directoryprotection](kasapi-cli_directoryprotection.md) - Inspect and manage directory (htaccess) protections (get/add/update/delete_directoryprotection) + diff --git a/docs/usage/destructive-writes.md b/docs/usage/destructive-writes.md index 74db5d5..cfd89f2 100644 --- a/docs/usage/destructive-writes.md +++ b/docs/usage/destructive-writes.md @@ -46,6 +46,7 @@ the deviations. | [`ddnsusers`](https://github.com/chmmou/kasapi-cli/issues/121) | **No `_new_password` split**: `--password` maps to `dyndns_password` on both `add` and `update`. `update_ddnsuser` accepts `--target-ipv4` / `--target-ipv6` instead of `add`'s legacy `--target-ip`; the ipv4/ipv6 keys are undocumented in the KAS API docs but verified to work against the live system (the captured update request fixture is authoritative). | | [`mail accounts`](https://github.com/chmmou/kasapi-cli/issues/114) | **Louder delete prompt**: `delete_mailaccount` uses `"permanently delete"` (shared with `databases`) — it drops the mailbox AND every message in it. `add` splits the address on the last `@` into `local_part` / `domain_part`; `update`/`delete` address the account by its generated `mail_login`. Password key splits between actions: `--password` → `mail_password` on add, `mail_new_password` on update. `add`'s Y/N/text toggles and XLIST folder names default to the KAS API's own defaults; `--responder` is passed through verbatim (`N`, `Y`, or a `\|` timestamp range). | | [`mail filters`](https://github.com/chmmou/kasapi-cli/issues/116) | **Both `add` and `delete` are gated**, not just `delete`: there is no `update_mailstandardfilter`, so `add_mailstandardfilter` *replaces* the configured chain wholesale (any previously-set items not in the new `--filter` list are dropped). Repeatable `--filter ` is joined with `;` on the wire (items must not contain `;` and not be empty). `delete_mailstandardfilter` takes only `` and removes the whole chain in one shot; the prompt phrases this as *"remove all standard filters of mail account <login>"* to make the all-at-once effect explicit. **Known API quirk**: `delete` sometimes returns an envelope-level SOAP fault (an internal `sizeof()` PHP error) even when the chain was in fact removed on the server. The fault is surfaced verbatim — if you see it, verify the actual outcome via `kasapi-cli mail accounts get ` (the configured chain is reported in the `mail_spamfilter` field). | +| [`directoryprotection`](https://github.com/chmmou/kasapi-cli/issues/123) | **Composite `(path, user)` identity**: `add`/`update`/`delete` take two positional args ` ` (a single path can protect several users), not one server-generated login — so the `add` identifier is user-supplied and not echoed back. **No `_new_password` split**: `--password` maps to `directory_password` on both `add` and `update`. `--authname` (the htaccess realm label) is optional and always sent on `add`; `update` sends only the changed `--password`/`--authname` (sparse), so an omitted password keeps the current one. KAS also accepts parallel `directory_user`/`directory_password` arrays to create several users in one call (hence the `directory_user_count_neq_passcount` fault); only the captured scalar single-user form is modelled. | ## The contract diff --git a/internal/cli/directoryprotection.go b/internal/cli/directoryprotection.go index 260f1a8..d2a0f01 100644 --- a/internal/cli/directoryprotection.go +++ b/internal/cli/directoryprotection.go @@ -2,6 +2,7 @@ package cli import ( "context" + "fmt" "github.com/spf13/cobra" @@ -10,17 +11,31 @@ import ( ) // NewDirectoryProtectionCmd returns the "kasapi-cli directoryprotection" -// subcommand tree. The KAS endpoint `get_directoryprotection` returns -// one entry per (path, user) tuple, so a directory with N users -// surfaces as N rows; for that reason this resource is exposed as a -// list with an optional `--path` filter rather than the usual -// list+get pair. +// subcommand tree: the list read endpoint plus the add / update / +// delete write endpoints (add/update/delete_directoryprotection). +// +// The KAS endpoint `get_directoryprotection` returns one entry per +// (path, user) tuple, so a directory with N users surfaces as N rows; +// for that reason the read side is exposed as a list with an optional +// `--path` filter rather than the usual list+get pair. A protection +// entry is likewise identified on the write side by the (path, user) +// pair, taken as the two positional arguments of add/update/delete. +// +// update and delete are gated by the #109 confirmation prompt: update +// replaces the access password (the previous one is unrecoverable) and +// delete revokes access, so both can lock users out. add is reversible +// (delete undoes it) and is not prompted. func NewDirectoryProtectionCmd(opts *RootOptions) *cobra.Command { cmd := &cobra.Command{ Use: "directoryprotection", - Short: "Inspect directory (htaccess) protections (get_directoryprotection)", + Short: "Inspect and manage directory (htaccess) protections (get/add/update/delete_directoryprotection)", } - cmd.AddCommand(newDirectoryProtectionListCmd(opts)) + cmd.AddCommand( + newDirectoryProtectionListCmd(opts), + newDirectoryProtectionAddCmd(opts), + newDirectoryProtectionUpdateCmd(opts), + newDirectoryProtectionDeleteCmd(opts), + ) return cmd } @@ -38,3 +53,105 @@ func newDirectoryProtectionListCmd(opts *RootOptions) *cobra.Command { "directory path to filter on (e.g. /protected/directory/); empty returns every protection") return cmd } + +// dpIdent renders the (path, user) identity of a protection entry for +// the confirm prompt / audit target and the success line, so the +// directory and the affected user are both visible (a path can carry +// several protected users). +func dpIdent(path, user string) string { + return path + " (user " + user + ")" +} + +func newDirectoryProtectionAddCmd(opts *RootOptions) *cobra.Command { + var password, authname string + cmd := &cobra.Command{ + Use: "add --password [--authname ]", + Short: "Protect a path for a user (add_directoryprotection)", + Args: cobra.ExactArgs(2), + RunE: runWriteE(opts, func(args []string) (writeSpec, error) { + path, user := args[0], args[1] + if password == "" { + return writeSpec{}, fmt.Errorf("--password is required") + } + s := directoryprotection.Spec{User: user, Path: path, Password: password, AuthName: authname} + return writeSpec{ + action: "add_directoryprotection", + destructive: false, + confirm: ConfirmAction{Verb: "create", Resource: "directory protection", ID: dpIdent(path, user)}, + params: directoryprotection.AddParams(s), + dispatch: func(c *api.Client, ctx context.Context) (string, error) { + if _, derr := directoryprotection.NewClient(c).Add(ctx, s); derr != nil { + return "", derr + } + return "created directory protection " + dpIdent(path, user), nil + }, + }, nil + }), + } + cmd.Flags().StringVar(&password, "password", "", "access password for the protected user (required)") + cmd.Flags().StringVar(&authname, "authname", "", "htaccess realm label shown in the browser auth dialog (optional; directory_authname)") + return cmd +} + +func newDirectoryProtectionUpdateCmd(opts *RootOptions) *cobra.Command { + var password, authname string + cmd := &cobra.Command{ + Use: "update [--password ] [--authname ]", + Short: "Replace the password and/or realm of a directory protection (update_directoryprotection)", + Args: cobra.ExactArgs(2), + } + cmd.RunE = runWriteE(opts, func(args []string) (writeSpec, error) { + path, user := args[0], args[1] + // Only the explicitly-set flags are sent (keyed on cobra + // Changed): an omitted --password keeps the current one rather + // than resetting it, and an omitted --authname keeps the realm. + fields := map[string]string{} + if cmd.Flags().Changed("password") { + fields[directoryprotection.FieldPassword] = password + } + if cmd.Flags().Changed("authname") { + fields[directoryprotection.FieldAuthName] = authname + } + if len(fields) == 0 { + return writeSpec{}, fmt.Errorf("at least one field flag (--password/--authname) is required") + } + return writeSpec{ + action: "update_directoryprotection", + destructive: true, + confirm: ConfirmAction{Verb: "replace the settings of", Resource: "directory protection", ID: dpIdent(path, user)}, + params: directoryprotection.UpdateParams(path, user, fields), + dispatch: func(c *api.Client, ctx context.Context) (string, error) { + if derr := directoryprotection.NewClient(c).Update(ctx, path, user, fields); derr != nil { + return "", derr + } + return "updated directory protection " + dpIdent(path, user), nil + }, + }, nil + }) + cmd.Flags().StringVar(&password, "password", "", "replacement access password (sent as directory_password)") + cmd.Flags().StringVar(&authname, "authname", "", "replacement htaccess realm label (directory_authname)") + return cmd +} + +func newDirectoryProtectionDeleteCmd(opts *RootOptions) *cobra.Command { + return &cobra.Command{ + Use: "delete ", + Short: "Revoke a user's directory protection on a path (delete_directoryprotection)", + Args: cobra.ExactArgs(2), + RunE: runWriteE(opts, func(args []string) (writeSpec, error) { + path, user := args[0], args[1] + return writeSpec{ + action: "delete_directoryprotection", + destructive: true, + confirm: ConfirmAction{Verb: "delete", Resource: "directory protection", ID: dpIdent(path, user)}, + params: directoryprotection.DeleteParams(path, user), + dispatch: func(c *api.Client, ctx context.Context) (string, error) { + if derr := directoryprotection.NewClient(c).Delete(ctx, path, user); derr != nil { + return "", derr + } + return "deleted directory protection " + dpIdent(path, user), nil + }, + }, nil + }), + } +} diff --git a/internal/cli/directoryprotection_test.go b/internal/cli/directoryprotection_test.go index 921984a..01cb563 100644 --- a/internal/cli/directoryprotection_test.go +++ b/internal/cli/directoryprotection_test.go @@ -2,6 +2,8 @@ package cli_test import ( "bytes" + "encoding/json" + "errors" "strings" "testing" @@ -20,8 +22,11 @@ func TestDirectoryProtectionCmdHelpListsSubcommands(t *testing.T) { if err := root.Execute(); err != nil { t.Fatalf("Execute: %v", err) } - if !strings.Contains(buf.String(), "list") { - t.Errorf("--help output missing 'list':\n%s", buf.String()) + out := buf.String() + for _, want := range []string{"list", "add", "update", "delete"} { + if !strings.Contains(out, want) { + t.Errorf("--help output missing %q\n%s", want, out) + } } } @@ -41,3 +46,159 @@ func TestDirectoryProtectionListHelpAdvertisesPathFlag(t *testing.T) { t.Errorf("list --help missing --path flag:\n%s", buf.String()) } } + +// add requires --password; the (path, user) identity comes from the two +// positional args, so a missing password must fail as a user error +// before any credentials are resolved. +func TestDirectoryProtectionAddRejectsMissingPassword(t *testing.T) { + t.Parallel() + root, opts := cli.NewRootCmd() + root.AddCommand(cli.NewDirectoryProtectionCmd(opts)) + var buf bytes.Buffer + root.SetOut(&buf) + root.SetErr(&buf) + root.SetArgs([]string{"directoryprotection", "add", "/protected/directory/", "protected_user"}) + err := root.Execute() + if err == nil { + t.Fatal("Execute: want error, got nil") + } + if cli.CodeFor(err) != cli.ExitUserError { + t.Errorf("exit code = %d, want ExitUserError", cli.CodeFor(err)) + } +} + +// update must send only the explicitly-changed flags (keyed on cobra +// Changed): a password-only update must not leak directory_authname, +// and an authname-only update must not leak directory_password (so an +// omitted password keeps the current one). The password value is +// redacted in the dry-run/audit preview, so the mapping is proven by +// the key's presence + the absence of the other field. +func TestDirectoryProtectionUpdateDryRunFieldAssembly(t *testing.T) { + t.Parallel() + cases := []struct { + name string + args []string + want map[string]string + absent []string + }{ + { + "password only (redacted)", + []string{"directoryprotection", "update", "/protected/directory/", "protected_user", "--password", "n3wpass"}, + map[string]string{ + "directory_path": "/protected/directory/", + "directory_user": "protected_user", + "directory_password": "", + }, + []string{"directory_authname"}, + }, + { + "authname only", + []string{"directoryprotection", "update", "/protected/directory/", "protected_user", "--authname", "Realm Only"}, + map[string]string{ + "directory_path": "/protected/directory/", + "directory_user": "protected_user", + "directory_authname": "Realm Only", + }, + []string{"directory_password"}, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + root, opts := cli.NewRootCmd() + root.AddCommand(cli.NewDirectoryProtectionCmd(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_directoryprotection" { + t.Errorf("action = %q, want update_directoryprotection", 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) + } + } + }) + } +} + +// The destructive subcommands (update/delete) must refuse on a +// non-interactive stdin without --yes rather than dispatch unconfirmed. +func TestDirectoryProtectionDestructiveRefuseNonTTY(t *testing.T) { + t.Parallel() + for _, args := range [][]string{ + {"directoryprotection", "delete", "/protected/directory/", "protected_user"}, + {"directoryprotection", "update", "/protected/directory/", "protected_user", "--authname", "x"}, + } { + t.Run(args[1], func(t *testing.T) { + t.Parallel() + root, opts := cli.NewRootCmd() + root.AddCommand(cli.NewDirectoryProtectionCmd(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) + } + }) + } +} + +// On --dry-run the runWriteE seam must still emit a #131 audit record +// (outcome=dry-run, action=delete_directoryprotection, identity fields) +// on stderr even though no SOAP call is dispatched, pinning the delete +// subcommand's wiring into the audit emission path. +func TestDirectoryProtectionDeleteDryRunEmitsAuditLine(t *testing.T) { + t.Parallel() + root, opts := cli.NewRootCmd() + root.AddCommand(cli.NewDirectoryProtectionCmd(opts)) + var out, errb bytes.Buffer + root.SetOut(&out) + root.SetErr(&errb) + root.SetArgs([]string{ + "directoryprotection", "delete", "/protected/directory/", "protected_user", + "--dry-run", + "--login", "w0000000", "--auth-data", "x", "--auth-type", "plain", + }) + if err := root.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + line := errb.String() + for _, want := range []string{ + "action=delete_directoryprotection", + "outcome=dry-run", + "directory_path=/protected/directory/", + "directory_user=protected_user", + "login=w0000000", + } { + if !strings.Contains(line, want) { + t.Errorf("audit line missing %q\nline: %s", want, line) + } + } +} diff --git a/internal/directoryprotection/directoryprotection_test.go b/internal/directoryprotection/directoryprotection_test.go index b008c17..0ecddae 100644 --- a/internal/directoryprotection/directoryprotection_test.go +++ b/internal/directoryprotection/directoryprotection_test.go @@ -16,11 +16,11 @@ func TestDecodeDirectoryProtections(t *testing.T) { if err != nil { t.Fatalf("DecodeDirectoryProtections: %v", err) } - if len(got) != 1 { - t.Fatalf("len = %d, want 1", len(got)) + if len(got) != 2 { + t.Fatalf("len = %d, want 2", len(got)) } d := got[0] - if d.User != "w0000000" { + if d.User != "protected_user" { t.Errorf("User = %q", d.User) } if d.Path != "/protected/directory/" { @@ -32,6 +32,12 @@ func TestDecodeDirectoryProtections(t *testing.T) { if d.InProgress != "FALSE" { t.Errorf("InProgress = %q", d.InProgress) } + // Second entry: a directory with multiple protected users surfaces + // as multiple list rows (the read shape this module deliberately + // exposes instead of a single Get). + if got[1].User != "protected_user_1" || got[1].Path != "/protected/directory/1/" { + t.Errorf("got[1] = %+v, want user=protected_user_1 path=/protected/directory/1/", got[1]) + } } func TestDecodeDirectoryProtectionSingular(t *testing.T) { @@ -108,8 +114,8 @@ func TestDirectoryProtectionListTabular(t *testing.T) { t.Errorf("headers[0] = %q, want PATH", headers[0]) } rows := list.TableRows() - if len(rows) != 1 { - t.Fatalf("rows = %d, want 1", len(rows)) + if len(rows) != 2 { + t.Fatalf("rows = %d, want 2", len(rows)) } if rows[0][0] != "/protected/directory/" { t.Errorf("rows[0][PATH] = %q", rows[0][0]) diff --git a/internal/directoryprotection/fault_test.go b/internal/directoryprotection/fault_test.go index 136335d..d52c3c7 100644 --- a/internal/directoryprotection/fault_test.go +++ b/internal/directoryprotection/fault_test.go @@ -9,7 +9,24 @@ import ( func TestFaultFixturesDecodeToDocumentedCodes(t *testing.T) { t.Parallel() testutil.AssertFaultFixtures(t, "directoryprotection", map[string]string{ + // add — including the faults distinctive to the multi-user + // array form KAS accepts (count mismatch, duplicate, max + // reached) even though this slice only sends the scalar form. "add_directoryprotection_response_failed_directory_authname_syntax_incorrect.xml": "directory_authname_syntax_incorrect", "add_directoryprotection_response_failed_directory_password_syntax_incorrect.xml": "directory_password_syntax_incorrect", + "add_directoryprotection_response_failed_directory_path_syntax_incorrect.xml": "directory_path_syntax_incorrect", + "add_directoryprotection_response_failed_directory_user_syntax_incorrect.xml": "directory_user_syntax_incorrect", + "add_directoryprotection_response_failed_directory_user_count_neq_passcount.xml": "directory_user_count_neq_passcount", + "add_directoryprotection_response_failed_duplicate_directory_user.xml": "duplicate_directory_user", + "add_directoryprotection_response_failed_max_directory_user_reached.xml": "max_directory_user_reached", + "add_directoryprotection_response_failed_missing_parameter.xml": "missing_parameter", + // update — nothing_to_do is the sparse-update fault (the API + // rejects an update that changes nothing). + "update_directoryprotection_response_failed_nothing_to_do.xml": "nothing_to_do", + "update_directoryprotection_response_failed_in_progress.xml": "in_progress", + "update_directoryprotection_response_failed_directory_password_syntax_incorrect.xml": "directory_password_syntax_incorrect", + // delete + "delete_directoryprotection_response_failed_in_progress.xml": "in_progress", + "delete_directoryprotection_response_failed_missing_parameter.xml": "missing_parameter", }) } diff --git a/internal/directoryprotection/write.go b/internal/directoryprotection/write.go new file mode 100644 index 0000000..ffa0e79 --- /dev/null +++ b/internal/directoryprotection/write.go @@ -0,0 +1,158 @@ +package directoryprotection + +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, +// directoryprotection.ErrUnexpectedReturnString) keeps working and the +// slice stays self-describing. See kaswrite.ErrUnexpectedReturnString +// for the full contract. +var ErrUnexpectedReturnString = kaswrite.ErrUnexpectedReturnString + +const ( + addAction = "add_directoryprotection" + updateAction = "update_directoryprotection" + deleteAction = "delete_directoryprotection" +) + +// The Field-prefixed constants are the KAS request keys the write +// actions accept. A directory-protection entry is identified by the +// (directory_path, directory_user) pair; directory_password and +// directory_authname are the mutable fields. +// +// Unlike the database / ftpuser / sambauser slices there is NO +// directory_new_password split: update_directoryprotection takes the +// replacement password under the same directory_password key add uses, +// so a single FieldPassword constant serves both actions (verified +// against the captured update request fixture). +const ( + FieldUser = "directory_user" + FieldPath = "directory_path" + FieldPassword = "directory_password" + FieldAuthName = "directory_authname" +) + +// Spec carries the add_directoryprotection request fields. User, Path +// and Password are required by the KAS API and validated as non-empty +// before any SOAP call so the CLI can surface a fast validation error; +// AuthName is the optional htaccess realm label (directory_authname) +// and is sent verbatim, empty included. +// +// KAS also accepts directory_user / directory_password as parallel +// arrays to create several protected users in one call (hence the +// directory_user_count_neq_passcount fault). The captured request +// fixtures only exercise the scalar single-user form, and the array +// wire-encoding is not captured, so this slice deliberately models one +// (path, user) protection per call rather than inventing the array +// shape. +type Spec struct { + User string + Path string + Password string + AuthName string +} + +// Add creates a directory protection (add_directoryprotection) for one +// (path, user) pair and returns the user as echoed in ReturnInfo. +// +// User, Path and Password are validated before the SOAP call so an +// obviously incomplete spec fails fast; the remaining syntax validation +// (directory_path_syntax_incorrect, directory_password_syntax_incorrect, +// directory_authname_syntax_incorrect, …) is left to the API and +// surfaces verbatim through the Caller. +func (cl *Client) Add(ctx context.Context, s Spec) (string, error) { + switch { + case s.User == "": + return "", errors.New("directoryprotection: add_directoryprotection requires a non-empty directory user") + case s.Path == "": + return "", errors.New("directoryprotection: add_directoryprotection requires a non-empty directory path") + case s.Password == "": + return "", errors.New("directoryprotection: add_directoryprotection requires a non-empty directory password") + } + resp, err := kaswrite.Call(ctx, cl.API, "directoryprotection", addAction, AddParams(s)) + if err != nil { + return "", err + } + return resp.Body.ReturnInfo.AsString(), nil +} + +// AddParams builds the add_directoryprotection 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. directory_authname is always sent (an empty realm label is +// a meaningful "no label", not a missing parameter). +func AddParams(s Spec) map[string]any { + return map[string]any{ + FieldUser: s.User, + FieldPath: s.Path, + FieldPassword: s.Password, + FieldAuthName: s.AuthName, + } +} + +// Update changes the mutable fields of an existing directory protection +// (update_directoryprotection). The entry is identified by the +// (path, user) pair; fields holds only the keys the caller wants to +// change (use FieldPassword / FieldAuthName), each applied wholesale. +// At least one field is required — update with nothing to change is +// rejected before the SOAP call (the API would fault nothing_to_do). +func (cl *Client) Update(ctx context.Context, path, user string, fields map[string]string) error { + switch { + case path == "": + return errors.New("directoryprotection: update_directoryprotection requires a non-empty directory path") + case user == "": + return errors.New("directoryprotection: update_directoryprotection requires a non-empty directory user") + } + if len(fields) == 0 { + return errors.New("directoryprotection: update_directoryprotection requires at least one field to change") + } + _, err := kaswrite.Call(ctx, cl.API, "directoryprotection", updateAction, UpdateParams(path, user, fields)) + return err +} + +// UpdateParams builds the update_directoryprotection KAS request +// parameter map (single source of truth, see AddParams): the +// (directory_path, directory_user) identity plus every caller-supplied +// mutable field verbatim. +func UpdateParams(path, user string, fields map[string]string) map[string]any { + params := map[string]any{ + FieldPath: path, + FieldUser: user, + } + for k, v := range fields { + params[k] = v + } + return params +} + +// Delete removes a directory protection (delete_directoryprotection) +// for one (path, user) pair. The action is destructive — it drops the +// access entry. The CLI gates it behind the #109 confirmation prompt; a +// SOAP fault (e.g. 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, path, user string) error { + switch { + case path == "": + return errors.New("directoryprotection: delete_directoryprotection requires a non-empty directory path") + case user == "": + return errors.New("directoryprotection: delete_directoryprotection requires a non-empty directory user") + } + _, err := kaswrite.Call(ctx, cl.API, "directoryprotection", deleteAction, DeleteParams(path, user)) + return err +} + +// DeleteParams builds the delete_directoryprotection KAS request +// parameter map (single source of truth, see AddParams). Only the +// (directory_path, directory_user) identity is sent — no password or +// authname. +func DeleteParams(path, user string) map[string]any { + return map[string]any{ + FieldPath: path, + FieldUser: user, + } +} diff --git a/internal/directoryprotection/write_test.go b/internal/directoryprotection/write_test.go new file mode 100644 index 0000000..705c73f --- /dev/null +++ b/internal/directoryprotection/write_test.go @@ -0,0 +1,237 @@ +package directoryprotection_test + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/chmmou/kasapi-cli/internal/directoryprotection" + "github.com/chmmou/kasapi-cli/internal/soap" + "github.com/chmmou/kasapi-cli/internal/testutil" +) + +func sampleSpec() directoryprotection.Spec { + return directoryprotection.Spec{ + User: "protected_user", + Path: "/protected/directory/", + Password: "s3cret", + AuthName: "Protected Area", + } +} + +func TestClientAdd(t *testing.T) { + t.Parallel() + resp := testutil.DecodeFixture(t, "directoryprotection/add_directoryprotection_response_success.xml") + fc := &testutil.FakeCaller{Resp: resp} + user, err := directoryprotection.NewClient(fc).Add(context.Background(), sampleSpec()) + if err != nil { + t.Fatalf("Add: %v", err) + } + if fc.GotAction != "add_directoryprotection" { + t.Errorf("action = %q, want add_directoryprotection", fc.GotAction) + } + wantParams := map[string]any{ + "directory_user": "protected_user", + "directory_path": "/protected/directory/", + "directory_password": "s3cret", + "directory_authname": "Protected Area", + } + 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) + } + } + if user != "protected_user" { + t.Errorf("returned user = %q, want protected_user (fixture ReturnInfo)", user) + } +} + +func TestClientUpdate(t *testing.T) { + t.Parallel() + resp := testutil.DecodeFixture(t, "directoryprotection/update_directoryprotection_response_success.xml") + fc := &testutil.FakeCaller{Resp: resp} + fields := map[string]string{ + directoryprotection.FieldPassword: "n3wpass", + directoryprotection.FieldAuthName: "Protected Area Updated", + } + if err := directoryprotection.NewClient(fc).Update(context.Background(), "/protected/directory/", "protected_user", fields); err != nil { + t.Fatalf("Update: %v", err) + } + if fc.GotAction != "update_directoryprotection" { + t.Errorf("action = %q, want update_directoryprotection", fc.GotAction) + } + wantParams := map[string]any{ + "directory_path": "/protected/directory/", + "directory_user": "protected_user", + "directory_password": "n3wpass", + "directory_authname": "Protected Area Updated", + } + 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 must send only the explicitly-changed fields (keyed on cobra +// Changed at the CLI layer): updating the authname alone must not leak +// a directory_password key, so an omitted password keeps the current +// one instead of being reset. +func TestClientUpdateSparse(t *testing.T) { + t.Parallel() + resp := testutil.DecodeFixture(t, "directoryprotection/update_directoryprotection_response_success.xml") + fc := &testutil.FakeCaller{Resp: resp} + fields := map[string]string{directoryprotection.FieldAuthName: "Realm Only"} + if err := directoryprotection.NewClient(fc).Update(context.Background(), "/protected/directory/", "protected_user", fields); err != nil { + t.Fatalf("Update: %v", err) + } + if _, ok := fc.GotParams["directory_password"]; ok { + t.Errorf("authname-only update must not send directory_password: %v", fc.GotParams) + } + if fc.GotParams["directory_authname"] != "Realm Only" { + t.Errorf("params[directory_authname] = %v, want Realm Only", fc.GotParams["directory_authname"]) + } +} + +func TestClientDelete(t *testing.T) { + t.Parallel() + resp := testutil.DecodeFixture(t, "directoryprotection/delete_directoryprotection_response_success.xml") + fc := &testutil.FakeCaller{Resp: resp} + if err := directoryprotection.NewClient(fc).Delete(context.Background(), "/protected/directory/", "protected_user"); err != nil { + t.Fatalf("Delete: %v", err) + } + if fc.GotAction != "delete_directoryprotection" { + t.Errorf("action = %q, want delete_directoryprotection", fc.GotAction) + } + if fc.GotParams["directory_path"] != "/protected/directory/" || fc.GotParams["directory_user"] != "protected_user" { + t.Errorf("params = %v, want only path+user identity", fc.GotParams) + } + // delete carries the identity only — no password / authname. + for _, k := range []string{"directory_password", "directory_authname"} { + if _, ok := fc.GotParams[k]; ok { + t.Errorf("delete_directoryprotection must not send %q: %v", k, fc.GotParams) + } + } +} + +func TestWriteValidation(t *testing.T) { + t.Parallel() + c := directoryprotection.NewClient(&testutil.FakeCaller{}) + ctx := context.Background() + + // Each missing-field case must surface a per-field validation error + // mentioning only that single field, not a combined message. + for _, tc := range []struct { + name string + mut func(*directoryprotection.Spec) + wantSub string + }{ + {"missing user", func(s *directoryprotection.Spec) { s.User = "" }, "user"}, + {"missing path", func(s *directoryprotection.Spec) { s.Path = "" }, "path"}, + {"missing password", func(s *directoryprotection.Spec) { s.Password = "" }, "password"}, + } { + s := sampleSpec() + tc.mut(&s) + _, err := c.Add(ctx, s) + if err == nil { + t.Errorf("Add %s: err = nil, want validation error", tc.name) + continue + } + if !strings.Contains(err.Error(), tc.wantSub) { + t.Errorf("Add %s: err = %q, want it to mention %q", tc.name, err.Error(), tc.wantSub) + } + } + // AuthName == "" is a deliberate "no realm label" value, not a + // missing parameter — Add must reach the SOAP call (intercepted by + // the FakeCaller with a success fixture). + resp := testutil.DecodeFixture(t, "directoryprotection/add_directoryprotection_response_success.xml") + emptyRealm := directoryprotection.NewClient(&testutil.FakeCaller{Resp: resp}) + s := sampleSpec() + s.AuthName = "" + if _, err := emptyRealm.Add(ctx, s); err != nil { + t.Errorf("Add with empty AuthName: err = %v, want nil (empty realm label is allowed)", err) + } + + if err := c.Update(ctx, "", "u", map[string]string{directoryprotection.FieldAuthName: "x"}); err == nil { + t.Error("Update empty path: err = nil, want validation error") + } + if err := c.Update(ctx, "/p/", "", map[string]string{directoryprotection.FieldAuthName: "x"}); err == nil { + t.Error("Update empty user: err = nil, want validation error") + } + if err := c.Update(ctx, "/p/", "u", nil); err == nil { + t.Error("Update no fields: err = nil, want validation error") + } + if err := c.Delete(ctx, "", "u"); err == nil { + t.Error("Delete empty path: err = nil, want validation error") + } + if err := c.Delete(ctx, "/p/", ""); err == nil { + t.Error("Delete empty user: err = nil, want validation error") + } +} + +func TestUnexpectedReturnString(t *testing.T) { + t.Parallel() + resp := &soap.Response{Body: soap.ResponseBody{ReturnString: "FALSE"}} + c := directoryprotection.NewClient(&testutil.FakeCaller{Resp: resp}) + ctx := context.Background() + if _, err := c.Add(ctx, sampleSpec()); !errors.Is(err, directoryprotection.ErrUnexpectedReturnString) { + t.Errorf("Add err = %v, want ErrUnexpectedReturnString", err) + } + if err := c.Update(ctx, "/p/", "u", map[string]string{directoryprotection.FieldAuthName: "x"}); !errors.Is(err, directoryprotection.ErrUnexpectedReturnString) { + t.Errorf("Update err = %v, want ErrUnexpectedReturnString", err) + } + if err := c.Delete(ctx, "/p/", "u"); !errors.Is(err, directoryprotection.ErrUnexpectedReturnString) { + t.Errorf("Delete err = %v, want ErrUnexpectedReturnString", err) + } +} + +func TestWritePropagatesError(t *testing.T) { + t.Parallel() + want := errors.New("boom") + c := directoryprotection.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, "/p/", "u", map[string]string{directoryprotection.FieldAuthName: "x"}); !errors.Is(err, want) { + t.Errorf("Update err = %v, want %v", err, want) + } + if err := c.Delete(ctx, "/p/", "u"); !errors.Is(err, want) { + t.Errorf("Delete err = %v, want %v", err, want) + } +} + +func TestParamBuilders(t *testing.T) { + t.Parallel() + add := directoryprotection.AddParams(sampleSpec()) + wantAdd := map[string]any{ + "directory_user": "protected_user", + "directory_path": "/protected/directory/", + "directory_password": "s3cret", + "directory_authname": "Protected Area", + } + for k, v := range wantAdd { + if add[k] != v { + t.Errorf("AddParams[%q] = %v, want %v", k, add[k], v) + } + } + if len(add) != 4 { + t.Errorf("AddParams has %d keys, want 4 (user/path/password/authname)", len(add)) + } + + upd := directoryprotection.UpdateParams("/protected/directory/", "protected_user", map[string]string{ + directoryprotection.FieldPassword: "n3wpass", + }) + if upd["directory_path"] != "/protected/directory/" || upd["directory_user"] != "protected_user" || upd["directory_password"] != "n3wpass" { + t.Errorf("UpdateParams = %v", upd) + } + if _, ok := upd["directory_authname"]; ok { + t.Errorf("UpdateParams must not contain unset directory_authname: %v", upd) + } + + del := directoryprotection.DeleteParams("/protected/directory/", "protected_user") + if len(del) != 2 || del["directory_path"] != "/protected/directory/" || del["directory_user"] != "protected_user" { + t.Errorf("DeleteParams = %v", del) + } +} diff --git a/testdata/directoryprotection/add_directoryprotection_response_success.xml b/testdata/directoryprotection/add_directoryprotection_response_success.xml index a757629..7701a0b 100644 --- a/testdata/directoryprotection/add_directoryprotection_response_success.xml +++ b/testdata/directoryprotection/add_directoryprotection_response_success.xml @@ -8,7 +8,7 @@ KasRequestTime - 1777737000 + 1780123652 KasRequestType @@ -25,6 +25,10 @@ directory_path /protected/directory/ + + directory_password + REDACTED + directory_authname Protected Area @@ -46,7 +50,7 @@ ReturnInfo - + protected_user Msg @@ -58,7 +62,7 @@ text - The directory protection will be installed. + Directory protection for protected_user is going to be created. @@ -68,4 +72,4 @@ - + \ No newline at end of file diff --git a/testdata/directoryprotection/delete_directoryprotection_response_success.xml b/testdata/directoryprotection/delete_directoryprotection_response_success.xml index 7c6af8e..6011b3d 100644 --- a/testdata/directoryprotection/delete_directoryprotection_response_success.xml +++ b/testdata/directoryprotection/delete_directoryprotection_response_success.xml @@ -8,7 +8,7 @@ KasRequestTime - 1777737240 + 1780123853 KasRequestType @@ -42,7 +42,7 @@ ReturnInfo - + protected_user Msg @@ -54,7 +54,7 @@ text - The directory protection is going to be deleted. + Directory protection for the folder /protected/directory/ has been deleted. @@ -64,4 +64,4 @@ - + \ No newline at end of file diff --git a/testdata/directoryprotection/get_directoryprotections_response_success.xml b/testdata/directoryprotection/get_directoryprotections_response_success.xml index 481b1a6..b824a60 100644 --- a/testdata/directoryprotection/get_directoryprotections_response_success.xml +++ b/testdata/directoryprotection/get_directoryprotections_response_success.xml @@ -8,7 +8,7 @@ KasRequestTime - 1777751550 + 1780124674 KasRequestType @@ -33,11 +33,11 @@ ReturnInfo - + directory_user - w0000000 + protected_user directory_path @@ -56,6 +56,28 @@ FALSE + + + directory_user + protected_user_1 + + + directory_path + /protected/directory/1/ + + + directory_authname + ByPassword 1 + + + directory_password + REDACTED + + + in_progress + FALSE + + diff --git a/testdata/directoryprotection/update_directoryprotection_response_success.xml b/testdata/directoryprotection/update_directoryprotection_response_success.xml index 6d2d8a8..5756d5a 100644 --- a/testdata/directoryprotection/update_directoryprotection_response_success.xml +++ b/testdata/directoryprotection/update_directoryprotection_response_success.xml @@ -8,7 +8,7 @@ KasRequestTime - 1777737120 + 1780124155 KasRequestType @@ -25,6 +25,10 @@ directory_path /protected/directory/ + + directory_password + REDACTED + directory_authname Protected Area Updated @@ -46,7 +50,7 @@ ReturnInfo - + protected_user Msg @@ -58,7 +62,7 @@ text - The directory protection has been edited. + 1 new users for the directory /temp/ have been created. @@ -68,4 +72,4 @@ - + \ No newline at end of file