diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d07d58..4b7a496 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -575,6 +575,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Whole-codebase re-review follow-up (fourth pass, Med + Low findings): + - The `mail lists update` `--config-file` / `--subscriber` blobs are + now elided from the audit record **by key** (`config` / + `subscriber`), independent of the multi-line/oversized shape + heuristic: a single-line list config short enough to pass that + heuristic could previously reach both audit sinks verbatim, + including an embedded cleartext list password — + `docs/usage/destructive-writes.md` already promised these blobs + never do. + - `kasapi-cli help ` now exits 1 like every other unknown + command: cobra's stock help command returns nil for an + unresolvable topic, so a typo'd topic read as success to scripts. + `help`, `help `, and `help ` keep + printing help with exit 0. + - The last `get_` fixture missed by the third-pass rename + sweep, `testdata/account/get_account_response_success.xml` + (embedding the real plural action `get_accounts`), was renamed to + `get_accounts_response_success_single.xml`. + - `config show` / `config path` / `config list-profiles` and + `sessions delete` now document in their `--help` that their output + is plain text and the global `--output` format flag has no effect + there, instead of silently ignoring it. + - The `mail accounts update` and `ddnsusers update` `--help` texts + now describe every field flag as a replacement value (the + convention the cronjob/ftpuser/sambauser/database update commands + already follow), instead of reusing `add`'s wording on all but the + password flag. + - The white-box `runWriteE` created_id test no longer couples + to host state — it pins `KAS_AUDIT_LOG` and points `--config` at a + nonexistent temp path so it cannot read the developer's real + config or append to a real audit log. + - Whole-codebase re-review follow-up (third pass, High + Med + Low findings): - Group commands (`mail`, `accounts`, `config`, ...) invoked with an diff --git a/docs/cli/kasapi-cli_config_list-profiles.md b/docs/cli/kasapi-cli_config_list-profiles.md index 8b0341d..cc9c1d2 100644 --- a/docs/cli/kasapi-cli_config_list-profiles.md +++ b/docs/cli/kasapi-cli_config_list-profiles.md @@ -2,6 +2,12 @@ List configured profiles and their auth_type (auth_data redacted) +### Synopsis + +List configured profiles alphabetically, one per line, with the default profile marked "*" and auth_data never shown. + +The output is plain text; the global --output format flag has no effect here. + ``` kasapi-cli config list-profiles [flags] ``` diff --git a/docs/cli/kasapi-cli_config_path.md b/docs/cli/kasapi-cli_config_path.md index 1a5fb78..f79fbd4 100644 --- a/docs/cli/kasapi-cli_config_path.md +++ b/docs/cli/kasapi-cli_config_path.md @@ -2,6 +2,12 @@ Print the resolved config-file path +### Synopsis + +Print the resolved config-file path. + +The output is a single plain-text line; the global --output format flag has no effect here. + ``` kasapi-cli config path [flags] ``` diff --git a/docs/cli/kasapi-cli_config_show.md b/docs/cli/kasapi-cli_config_show.md index aaa2081..7b663b2 100644 --- a/docs/cli/kasapi-cli_config_show.md +++ b/docs/cli/kasapi-cli_config_show.md @@ -2,6 +2,12 @@ Print the resolved effective config (auth_data redacted) +### Synopsis + +Print the resolved effective config: config path, profiles, and the config+env+flag credential resolution result, with auth_data redacted. + +The output is plain key: value text; the global --output format flag has no effect here. + ``` kasapi-cli config show [flags] ``` diff --git a/docs/cli/kasapi-cli_ddnsusers_update.md b/docs/cli/kasapi-cli_ddnsusers_update.md index 5121b13..d89f1b7 100644 --- a/docs/cli/kasapi-cli_ddnsusers_update.md +++ b/docs/cli/kasapi-cli_ddnsusers_update.md @@ -10,11 +10,11 @@ kasapi-cli ddnsusers update [password/target/dual-stack flags] [f ``` --comment string replacement user comment / label - --dual-stack enable dual-stack (Y) for this user; pass --dual-stack=false to disable + --dual-stack replacement dual-stack setting: Y when set, --dual-stack=false sends N -h, --help help for update --password string replacement DDNS password - --target-ipv4 string IPv4 target (undocumented but verified) - --target-ipv6 string IPv6 target (undocumented but verified) + --target-ipv4 string replacement IPv4 target (undocumented but verified) + --target-ipv6 string replacement IPv6 target (undocumented but verified) ``` ### Options inherited from parent commands diff --git a/docs/cli/kasapi-cli_mail_accounts_update.md b/docs/cli/kasapi-cli_mail_accounts_update.md index 2de778f..5c78b86 100644 --- a/docs/cli/kasapi-cli_mail_accounts_update.md +++ b/docs/cli/kasapi-cli_mail_accounts_update.md @@ -9,23 +9,23 @@ kasapi-cli mail accounts update [field flags] [flags] ### Options ``` - --active string mailbox status (Y|N) - --allow-nets string restrict access to these IP/CIDR networks - --copy-address string BCC copy recipient address(es) + --active string replacement mailbox status (Y|N) + --allow-nets string replacement IP/CIDR network access restriction (an explicitly empty value clears it) + --copy-address string replacement BCC copy recipient address(es) -h, --help help for update --password string replacement mailbox password (sent as mail_new_password) - --responder string auto-responder: "N", "Y", or a "|" timestamp range - --responder-content-type string auto-responder body format (html|text) - --responder-displayname string auto-responder sender display name - --responder-text string auto-responder message body - --sender-alias string permitted FROM alias address(es) - --webmail-autologin string allow KAS-to-webmail auto-login (Y|N) - --xlist-archiv string XLIST archive folder name - --xlist-drafts string XLIST drafts folder name - --xlist-enabled string enable XLIST special-folder mapping (Y|N) - --xlist-sent string XLIST sent-items folder name - --xlist-spam string XLIST spam folder name - --xlist-trash string XLIST trash folder name + --responder string replacement auto-responder setting: "N", "Y", or a "|" timestamp range + --responder-content-type string replacement auto-responder body format (html|text) + --responder-displayname string replacement auto-responder sender display name + --responder-text string replacement auto-responder message body + --sender-alias string replacement permitted FROM alias address(es) + --webmail-autologin string replacement KAS-to-webmail auto-login setting (Y|N) + --xlist-archiv string replacement XLIST archive folder name + --xlist-drafts string replacement XLIST drafts folder name + --xlist-enabled string replacement XLIST special-folder mapping setting (Y|N) + --xlist-sent string replacement XLIST sent-items folder name + --xlist-spam string replacement XLIST spam folder name + --xlist-trash string replacement XLIST trash folder name ``` ### Options inherited from parent commands diff --git a/docs/cli/kasapi-cli_sessions_delete.md b/docs/cli/kasapi-cli_sessions_delete.md index ec35d82..f8ddc39 100644 --- a/docs/cli/kasapi-cli_sessions_delete.md +++ b/docs/cli/kasapi-cli_sessions_delete.md @@ -8,6 +8,8 @@ Invalidate the resolved profile's cached session token, both server-side (kas_ac Acts on the *currently cached* token only; it never bootstraps a fresh token just to delete it. Idempotent: a missing or already-invalid session is reported and exits 0. No confirmation prompt — deleting a session merely forces a re-authentication on the next session-mode call. +The output is plain text; the global --output format flag has no effect here. + ``` kasapi-cli sessions delete [flags] ``` diff --git a/docs/usage/destructive-writes.md b/docs/usage/destructive-writes.md index 57537fd..dfd2cc9 100644 --- a/docs/usage/destructive-writes.md +++ b/docs/usage/destructive-writes.md @@ -126,10 +126,11 @@ additionally appends the same record as one JSON object per line Secret request parameters (`auth_data`, `*password`, `*token`, `*secret`, …) are replaced with `` in **both** sinks and never -written. Multi-line or oversized values (e.g. the `mail lists update` -`--config-file` / `--subscriber` blobs, which can contain the list -password) are elided to ``. Read commands produce no -audit record. +written. The `mail lists update` `--config-file` / `--subscriber` blobs +(which can contain the list password) are always elided to +``, keyed on the parameter name and independent of +their size; any other multi-line or oversized value is elided the same +way. Read commands produce no audit record. ## `--dry-run`: preview without dispatching diff --git a/internal/account/account_test.go b/internal/account/account_test.go index 3198519..fdbe579 100644 --- a/internal/account/account_test.go +++ b/internal/account/account_test.go @@ -158,7 +158,7 @@ func TestClientList(t *testing.T) { func TestClientGet(t *testing.T) { t.Parallel() - resp := testutil.DecodeFixture(t, "account/get_account_response_success.xml") + resp := testutil.DecodeFixture(t, "account/get_accounts_response_success_single.xml") fc := &testutil.FakeCaller{Resp: resp} got, err := account.NewClient(fc).Get(context.Background(), "w0000001") if err != nil { @@ -190,7 +190,7 @@ func TestClientGetNotFound(t *testing.T) { t.Parallel() // Synthesise an empty array response by reusing the singular // fixture but with the array stripped to zero entries. - emptyResp := testutil.DecodeFixture(t, "account/get_account_response_success.xml") + emptyResp := testutil.DecodeFixture(t, "account/get_accounts_response_success_single.xml") emptyResp.Body.ReturnInfo.Array = nil c := account.NewClient(&testutil.FakeCaller{Resp: emptyResp}) if _, err := c.Get(context.Background(), "wXXXXXXX"); err == nil { @@ -230,7 +230,7 @@ func TestAccountListTabular(t *testing.T) { func TestAccountTabular(t *testing.T) { t.Parallel() - resp := testutil.DecodeFixture(t, "account/get_account_response_success.xml") + resp := testutil.DecodeFixture(t, "account/get_accounts_response_success_single.xml") accs, _ := account.DecodeAccounts(resp.Body.ReturnInfo) if len(accs) != 1 { t.Fatalf("len = %d, want 1", len(accs)) diff --git a/internal/cli/audit.go b/internal/cli/audit.go index d201f0f..c0131f6 100644 --- a/internal/cli/audit.go +++ b/internal/cli/audit.go @@ -79,6 +79,17 @@ var auditSecretParams = map[string]struct{}{ const auditRedacted = "" +// auditBlobParams are wholesale-replacement blob keys +// (update_mailinglist's config / subscriber) whose content is always +// elided by key, independent of the size/shape heuristic below: the +// list config can embed the list password in cleartext, and a +// single-line config under maxAuditValueLen would otherwise reach both +// audit sinks verbatim. +var auditBlobParams = map[string]struct{}{ + "config": {}, + "subscriber": {}, +} + // maxAuditValueLen caps how long a single parameter value may be before // RedactParams elides it. Normal write parameters (names, hosts, Y/N // toggles) are far shorter; only wholesale blobs like the mailing-list @@ -106,12 +117,12 @@ func redactParam(key string) bool { // RedactParams converts a KAS request/response parameter map into the // string map stored on AuditRecord.Fields, replacing every secret value -// (see redactParam) with auditRedacted. Multi-line or oversized values -// (mailing-list config / subscriber blobs sent wholesale by -// update_mailinglist) are elided to a "" marker: the -// list config can carry the list password in cleartext, so the blob -// content must never reach either audit sink. Non-string values are -// rendered with %v. A nil/empty map yields nil so the field is omitted. +// (see redactParam) with auditRedacted. Wholesale blob keys +// (auditBlobParams) and any multi-line or oversized value are elided to +// a "" marker: the mailing-list config can carry the +// list password in cleartext, so the blob content must never reach +// either audit sink. Non-string values are rendered with %v. A +// nil/empty map yields nil so the field is omitted. func RedactParams(params map[string]any) map[string]string { if len(params) == 0 { return nil @@ -123,7 +134,8 @@ func RedactParams(params map[string]any) map[string]string { continue } s := fmt.Sprintf("%v", v) - if strings.ContainsAny(s, "\n\r") || len(s) > maxAuditValueLen { + _, blob := auditBlobParams[strings.ToLower(k)] + if blob || strings.ContainsAny(s, "\n\r") || len(s) > maxAuditValueLen { s = fmt.Sprintf("", len(s)) } out[k] = s diff --git a/internal/cli/audit_test.go b/internal/cli/audit_test.go index 50877ab..2806164 100644 --- a/internal/cli/audit_test.go +++ b/internal/cli/audit_test.go @@ -54,23 +54,29 @@ func TestRedactParams(t *testing.T) { } } -// Multi-line or oversized parameter values (the wholesale mailing-list -// config / subscriber blobs of update_mailinglist) must never reach the -// audit sinks verbatim: the list config can carry the list password in -// cleartext. +// The wholesale mailing-list config / subscriber blobs of +// update_mailinglist must never reach the audit sinks verbatim: the +// list config can carry the list password in cleartext. They are +// elided by key — a single-line config short enough to pass the +// size/shape heuristic must still be elided — and any other multi-line +// or oversized value is elided by shape. func TestRedactParamsElidesBlobs(t *testing.T) { t.Parallel() got := cli.RedactParams(map[string]any{ - "config": "line1\npassword secret123\n", - "subscriber": "a@x.de\rb@x.de", - "long": strings.Repeat("x", 300), + "config": "keep_password secret123", // single-line, short: elided by key + "subscriber": "a@x.de", // single-line, short: elided by key + "notes": "line1\nline2", // elided by shape (multi-line) + "long": strings.Repeat("x", 300), // elided by shape (oversized) "comment": "short stays", }) - if got["config"] != "" { - t.Errorf("config = %q, want ", got["config"]) + if got["config"] != "" { + t.Errorf("config = %q, want ", got["config"]) } - if got["subscriber"] != "" { - t.Errorf("subscriber = %q, want ", got["subscriber"]) + if got["subscriber"] != "" { + t.Errorf("subscriber = %q, want ", got["subscriber"]) + } + if got["notes"] != "" { + t.Errorf("notes = %q, want ", got["notes"]) } if got["long"] != "" { t.Errorf("long = %q, want ", got["long"]) diff --git a/internal/cli/config.go b/internal/cli/config.go index ade4a40..2693f7e 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -48,7 +48,10 @@ func newConfigPathCmd(opts *RootOptions) *cobra.Command { return &cobra.Command{ Use: "path", Short: "Print the resolved config-file path", - Args: cobra.NoArgs, + Long: "Print the resolved config-file path.\n\n" + + "The output is a single plain-text line; the global --output " + + "format flag has no effect here.", + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { path, err := resolveConfigPath(opts.ConfigPath) if err != nil { @@ -66,7 +69,12 @@ func newConfigShowCmd(opts *RootOptions) *cobra.Command { return &cobra.Command{ Use: "show", Short: "Print the resolved effective config (auth_data redacted)", - Args: cobra.NoArgs, + Long: "Print the resolved effective config: config path, profiles, " + + "and the config+env+flag credential resolution result, with " + + "auth_data redacted.\n\n" + + "The output is plain key: value text; the global --output " + + "format flag has no effect here.", + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { return runConfigShow(opts, cmd.OutOrStdout()) }, @@ -508,7 +516,11 @@ func newConfigListProfilesCmd(opts *RootOptions) *cobra.Command { return &cobra.Command{ Use: "list-profiles", Short: "List configured profiles and their auth_type (auth_data redacted)", - Args: cobra.NoArgs, + Long: "List configured profiles alphabetically, one per line, with " + + "the default profile marked \"*\" and auth_data never shown.\n\n" + + "The output is plain text; the global --output format flag has " + + "no effect here.", + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { return runConfigListProfiles(opts.ConfigPath, cmd.OutOrStdout()) }, diff --git a/internal/cli/ddnsusers.go b/internal/cli/ddnsusers.go index 3adcc69..2384b71 100644 --- a/internal/cli/ddnsusers.go +++ b/internal/cli/ddnsusers.go @@ -170,9 +170,9 @@ func (f *ddnsuserUpdateFlags) bind(cmd *cobra.Command) { fl := cmd.Flags() fl.StringVar(&f.password, "password", "", "replacement DDNS password") fl.StringVar(&f.comment, "comment", "", "replacement user comment / label") - fl.StringVar(&f.targetIPv4, "target-ipv4", "", "IPv4 target (undocumented but verified)") - fl.StringVar(&f.targetIPv6, "target-ipv6", "", "IPv6 target (undocumented but verified)") - fl.BoolVar(&f.dualStack, "dual-stack", false, "enable dual-stack (Y) for this user; pass --dual-stack=false to disable") + fl.StringVar(&f.targetIPv4, "target-ipv4", "", "replacement IPv4 target (undocumented but verified)") + fl.StringVar(&f.targetIPv6, "target-ipv6", "", "replacement IPv6 target (undocumented but verified)") + fl.BoolVar(&f.dualStack, "dual-stack", false, "replacement dual-stack setting: Y when set, --dual-stack=false sends N") } // ddnsuserUpdateChangedFields collects only the flags the user diff --git a/internal/cli/mail.go b/internal/cli/mail.go index f1a7374..940fdc7 100644 --- a/internal/cli/mail.go +++ b/internal/cli/mail.go @@ -465,21 +465,21 @@ type mailAccountUpdateFlags struct { func (f *mailAccountUpdateFlags) bind(cmd *cobra.Command) { fl := cmd.Flags() fl.StringVar(&f.password, "password", "", "replacement mailbox password (sent as mail_new_password)") - fl.StringVar(&f.active, "active", "", "mailbox status (Y|N)") - fl.StringVar(&f.webmailAutologin, "webmail-autologin", "", "allow KAS-to-webmail auto-login (Y|N)") - fl.StringVar(&f.responder, "responder", "", `auto-responder: "N", "Y", or a "|" timestamp range`) - fl.StringVar(&f.responderContentType, "responder-content-type", "", "auto-responder body format (html|text)") - fl.StringVar(&f.responderDisplayName, "responder-displayname", "", "auto-responder sender display name") - fl.StringVar(&f.responderText, "responder-text", "", "auto-responder message body") - fl.StringVar(&f.copyAddress, "copy-address", "", "BCC copy recipient address(es)") - fl.StringVar(&f.senderAlias, "sender-alias", "", "permitted FROM alias address(es)") - fl.StringVar(&f.xlistEnabled, "xlist-enabled", "", "enable XLIST special-folder mapping (Y|N)") - fl.StringVar(&f.xlistSent, "xlist-sent", "", "XLIST sent-items folder name") - fl.StringVar(&f.xlistDrafts, "xlist-drafts", "", "XLIST drafts folder name") - fl.StringVar(&f.xlistTrash, "xlist-trash", "", "XLIST trash folder name") - fl.StringVar(&f.xlistSpam, "xlist-spam", "", "XLIST spam folder name") - fl.StringVar(&f.xlistArchiv, "xlist-archiv", "", "XLIST archive folder name") - fl.StringVar(&f.allowNets, "allow-nets", "", "restrict access to these IP/CIDR networks") + fl.StringVar(&f.active, "active", "", "replacement mailbox status (Y|N)") + fl.StringVar(&f.webmailAutologin, "webmail-autologin", "", "replacement KAS-to-webmail auto-login setting (Y|N)") + fl.StringVar(&f.responder, "responder", "", `replacement auto-responder setting: "N", "Y", or a "|" timestamp range`) + fl.StringVar(&f.responderContentType, "responder-content-type", "", "replacement auto-responder body format (html|text)") + fl.StringVar(&f.responderDisplayName, "responder-displayname", "", "replacement auto-responder sender display name") + fl.StringVar(&f.responderText, "responder-text", "", "replacement auto-responder message body") + fl.StringVar(&f.copyAddress, "copy-address", "", "replacement BCC copy recipient address(es)") + fl.StringVar(&f.senderAlias, "sender-alias", "", "replacement permitted FROM alias address(es)") + fl.StringVar(&f.xlistEnabled, "xlist-enabled", "", "replacement XLIST special-folder mapping setting (Y|N)") + fl.StringVar(&f.xlistSent, "xlist-sent", "", "replacement XLIST sent-items folder name") + fl.StringVar(&f.xlistDrafts, "xlist-drafts", "", "replacement XLIST drafts folder name") + fl.StringVar(&f.xlistTrash, "xlist-trash", "", "replacement XLIST trash folder name") + fl.StringVar(&f.xlistSpam, "xlist-spam", "", "replacement XLIST spam folder name") + fl.StringVar(&f.xlistArchiv, "xlist-archiv", "", "replacement XLIST archive folder name") + fl.StringVar(&f.allowNets, "allow-nets", "", "replacement IP/CIDR network access restriction (an explicitly empty value clears it)") } // mailAccountUpdateChangedFields collects only the flags the user diff --git a/internal/cli/root.go b/internal/cli/root.go index 6713049..b06e072 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -143,10 +143,44 @@ func unknownSubcommandArgs(cmd *cobra.Command, args []string) error { // cmd/kasapi-cli, so tests exercise exactly the wiring the binary runs. func Finalize(root *cobra.Command) { root.InitDefaultCompletionCmd() + rejectUnknownHelpTopics(root) RejectUnknownSubcommands(root) MarkArgErrorsAsUserErrors(root) } +// rejectUnknownHelpTopics replaces the stock help command's Run with a +// RunE so `kasapi-cli help nonsense` exits 1 like every other unknown +// command. Cobra's default prints "Unknown help topic" (or, with a +// non-nil root Args validator, the root help) and returns nil — a +// typo'd topic would read as success to scripts, contradicting the +// unknown-subcommand contract established by RejectUnknownSubcommands. +func rejectUnknownHelpTopics(root *cobra.Command) { + root.InitDefaultHelpCmd() + for _, sub := range root.Commands() { + if sub.Name() != "help" { + continue + } + sub.Run = nil + sub.RunE = func(c *cobra.Command, args []string) error { + target, rest, err := c.Root().Find(args) + // Find only consumes resolved command names; leftover + // non-flag args are an unresolved topic path. + for _, a := range rest { + if !strings.HasPrefix(a, "-") { + err = errors.New("unresolved args") + } + } + if target == nil || err != nil { + return UserError(fmt.Errorf("unknown help topic %q for %q", strings.Join(args, " "), root.CommandPath()), "") + } + target.InitDefaultHelpFlag() + target.InitDefaultVersionFlag() + return target.Help() + } + return + } +} + // RejectUnknownSubcommands walks the command tree and gives every // non-runnable group command (mail, accounts, config, ...) an explicit // unknown-subcommand rejection. Without it cobra treats a group invoked diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 350e4a6..830d0d1 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -194,6 +194,56 @@ func TestCompletionArgErrorsExitUserError(t *testing.T) { } } +// TestHelpUnknownTopicExitsUserError pins that `help ` exits +// 1 like every other unknown command: cobra's stock help command +// returns nil for an unresolvable topic, which would read as success +// to scripts. +func TestHelpUnknownTopicExitsUserError(t *testing.T) { + t.Parallel() + root, opts := cli.NewRootCmd() + root.AddCommand(cli.NewMailCmd(opts)) + cli.Finalize(root) + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"help", "frobnicate"}) + err := root.Execute() + if err == nil { + t.Fatal("Execute help frobnicate: want error, got nil") + } + if cli.CodeFor(err) != cli.ExitUserError { + t.Errorf("unknown help topic should map to ExitUserError, got %d", cli.CodeFor(err)) + } + if !strings.Contains(err.Error(), "unknown help topic") { + t.Errorf("error message: %q", err.Error()) + } +} + +// `help ` and bare `help` keep printing help with +// exit 0 after the unknown-topic hardening. +func TestHelpKnownTopicsPrintHelp(t *testing.T) { + t.Parallel() + for _, args := range [][]string{ + {"help"}, + {"help", "mail"}, + {"help", "mail", "accounts"}, + } { + root, opts := cli.NewRootCmd() + root.AddCommand(cli.NewMailCmd(opts)) + cli.Finalize(root) + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs(args) + if err := root.Execute(); err != nil { + t.Fatalf("Execute %v: %v", args, err) + } + if out.Len() == 0 { + t.Errorf("Execute %v printed no help output", args) + } + } +} + // TestMarkArgErrorsAsUserErrors pins the exit-code contract for a // positional-args validation failure (e.g. a missing required argument // on an ExactArgs(1) subcommand) after the cmd/kasapi-cli wiring has diff --git a/internal/cli/run_internal_test.go b/internal/cli/run_internal_test.go index 7c7479b..745d1c0 100644 --- a/internal/cli/run_internal_test.go +++ b/internal/cli/run_internal_test.go @@ -10,6 +10,7 @@ import ( "context" "errors" "fmt" + "path/filepath" "strings" "testing" @@ -23,12 +24,18 @@ import ( // in the success audit record (created_id), not only in the rendered // success line. func TestRunWriteESuccessAuditCarriesCreatedID(t *testing.T) { - t.Parallel() + // No t.Parallel(): t.Setenv forbids it. The env override and the + // nonexistent --config path keep the test hermetic — without them + // resolveCreds would read the developer's real config file and a + // host-set KAS_AUDIT_LOG would make runWriteE append to the real + // audit log. + t.Setenv("KAS_AUDIT_LOG", "") opts := &RootOptions{ - Login: "w0000000", - AuthData: "x", - AuthType: "plain", - Output: FormatTable, + ConfigPath: filepath.Join(t.TempDir(), "config.toml"), + Login: "w0000000", + AuthData: "x", + AuthType: "plain", + Output: FormatTable, } var createdID string run := runWriteE(opts, func([]string) (writeSpec, error) { diff --git a/internal/cli/sessions.go b/internal/cli/sessions.go index 9f649d9..a389985 100644 --- a/internal/cli/sessions.go +++ b/internal/cli/sessions.go @@ -45,7 +45,9 @@ func newSessionsDeleteCmd(opts *RootOptions) *cobra.Command { "fresh token just to delete it. Idempotent: a missing or " + "already-invalid session is reported and exits 0. No confirmation " + "prompt — deleting a session merely forces a re-authentication on " + - "the next session-mode call.", + "the next session-mode call.\n\n" + + "The output is plain text; the global --output format flag has " + + "no effect here.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { logger := buildLogger(opts.Verbose) diff --git a/testdata/account/get_account_response_success.xml b/testdata/account/get_accounts_response_success_single.xml similarity index 100% rename from testdata/account/get_account_response_success.xml rename to testdata/account/get_accounts_response_success_single.xml