diff --git a/internal/cli/account_list_test.go b/internal/cli/account_list_test.go new file mode 100644 index 0000000..4770aa4 --- /dev/null +++ b/internal/cli/account_list_test.go @@ -0,0 +1,85 @@ +package cli + +import ( + "encoding/json" + "strings" + "testing" +) + +// With only EMCLI_KEY set, `account list` emits the reduced JSON envelope: +// name/from/can_send, and never the IMAP host or login username. +func TestAccountListAgentJSONView(t *testing.T) { + adminEnv(t) // both keys + initialized temp DB + run(t, "account", "add", "--name", "work", "--mode", "RW", + "--imap-host", "imap.example.com", "--smtp-host", "smtp.example.com", + "--username", "login@example.com", "--from", "me@example.com") + run(t, "account", "add", "--name", "alerts", "--mode", "RO", + "--imap-host", "imap.example.com", "--username", "alerts@example.com") + + // Drop the admin key → caller is an agent. + t.Setenv("EMCLI_ADMIN_KEY", "") + code, out, errOut := run(t, "account", "list") + if code != 0 { + t.Fatalf("agent account list should succeed: code=%d err=%q", code, errOut) + } + + var env struct { + Error bool `json:"error"` + Data struct { + Accounts []struct { + Name string `json:"name"` + From string `json:"from"` + CanSend bool `json:"can_send"` + } `json:"accounts"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(out), &env); err != nil { + t.Fatalf("output is not the agent envelope: %v\n%s", err, out) + } + if env.Error || len(env.Data.Accounts) != 2 { + t.Fatalf("want 2 accounts and no error, got %+v", env) + } + // The reduced view must not leak the IMAP host or the login username. + if strings.Contains(out, "imap.example.com") || strings.Contains(out, "login@example.com") { + t.Fatalf("agent view leaked host/username:\n%s", out) + } + + got := map[string]struct { + from string + canSend bool + }{} + for _, a := range env.Data.Accounts { + got[a.Name] = struct { + from string + canSend bool + }{a.From, a.CanSend} + } + if g := got["work"]; g.from != "me@example.com" || !g.canSend { + t.Errorf("work: want from=me@example.com can_send=true, got %+v", g) + } + // alerts has no --from → SendFrom() falls back to the username. + if g := got["alerts"]; g.from != "alerts@example.com" || g.canSend { + t.Errorf("alerts: want from=alerts@example.com can_send=false, got %+v", g) + } +} + +// With the admin key present, `account list` stays the full human-readable table. +func TestAccountListAdminTextView(t *testing.T) { + adminEnv(t) + run(t, "account", "add", "--name", "work", "--mode", "RW", + "--imap-host", "imap.example.com", "--smtp-host", "smtp.example.com", + "--username", "login@example.com", "--from", "me@example.com") + + code, out, _ := run(t, "account", "list") + if code != 0 { + t.Fatalf("admin account list failed: code=%d", code) + } + for _, want := range []string{"NAME", "MODE", "IMAP", "USER", "imap.example.com:993", "login@example.com"} { + if !strings.Contains(out, want) { + t.Fatalf("admin view missing %q:\n%s", want, out) + } + } + if strings.Contains(out, `"accounts"`) { + t.Fatalf("admin view should be text, not JSON:\n%s", out) + } +} diff --git a/internal/cli/admin.go b/internal/cli/admin.go index dabfcb3..804d220 100644 --- a/internal/cli/admin.go +++ b/internal/cli/admin.go @@ -6,6 +6,7 @@ import ( "io" "strconv" + "git.dcglab.co.uk/steve/emcli/internal/crypto" "git.dcglab.co.uk/steve/emcli/internal/store" "git.dcglab.co.uk/steve/emcli/internal/tui" ) @@ -23,7 +24,14 @@ func runAccount(args []string, role store.Role, out, errOut io.Writer) int { sub, rest := args[0], args[1:] st, err := openStore(role) if err != nil { - fmt.Fprintf(errOut, "emcli: %v\n", err) + // account list is an agent command (a JSON consumer), so its + // open/key failures are emitted as an envelope, like the other agent + // commands; the admin subcommands stay human-readable. + if sub == "list" { + _ = Failure(CodeConfig, err.Error()).Write(out) + } else { + fmt.Fprintf(errOut, "emcli: %v\n", err) + } return 1 } defer st.Close() @@ -171,11 +179,31 @@ func runAccount(args []string, role store.Role, out, errOut io.Writer) int { fmt.Fprintf(out, "account %q removed\n", *name) return 0 case "list": + // Holding the admin key means the caller is the human admin (full + // detail). An agent holds only EMCLI_KEY and gets a reduced JSON view. + _, adminErr := crypto.AdminKeyFromEnv() + isAdmin := adminErr == nil accs, err := st.ListAccounts() if err != nil { - fmt.Fprintf(errOut, "list: %v\n", err) + if isAdmin { + fmt.Fprintf(errOut, "list: %v\n", err) + } else { + _ = Failure(CodeDB, err.Error()).Write(out) + } return 1 } + if !isAdmin { + items := make([]map[string]any, 0, len(accs)) + for _, a := range accs { + items = append(items, map[string]any{ + "name": a.Name, + "from": a.SendFrom(), + "can_send": a.Mode == "RW", + }) + } + _ = Success(map[string]any{"accounts": items}).Write(out) + return 0 + } fmt.Fprintf(out, "%-16s %-4s %-28s %s\n", "NAME", "MODE", "IMAP", "USER") for _, a := range accs { fmt.Fprintf(out, "%-16s %-4s %-28s %s\n", diff --git a/internal/cli/role_test.go b/internal/cli/role_test.go index 647bf93..c62bcfa 100644 --- a/internal/cli/role_test.go +++ b/internal/cli/role_test.go @@ -9,16 +9,22 @@ import ( ) func TestCommandRole(t *testing.T) { - admin := []string{"account", "whitelist", "config", "audit"} - agent := []string{"list", "get", "search", "ack", "send", "doctor"} - for _, c := range admin { + adminCmds := [][]string{ + {"whitelist"}, {"config"}, {"audit"}, + {"account"}, {"account", "add"}, {"account", "edit"}, {"account", "remove"}, + } + agentCmds := [][]string{ + {"list"}, {"get"}, {"search"}, {"ack"}, {"send"}, {"doctor"}, + {"account", "list"}, + } + for _, c := range adminCmds { if commandRole(c) != store.RoleAdmin { - t.Errorf("%s should be admin", c) + t.Errorf("%v should be admin", c) } } - for _, c := range agent { + for _, c := range agentCmds { if commandRole(c) != store.RoleAgent { - t.Errorf("%s should be agent", c) + t.Errorf("%v should be agent", c) } } } diff --git a/internal/cli/run.go b/internal/cli/run.go index abeb5c0..62b6208 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -27,9 +27,16 @@ func realMailer(acc store.Account) (Mailer, error) { // commandRole maps a command to the privilege it requires. Admin commands // mutate configuration or expose oversight data; everything else is agent. -func commandRole(cmd string) store.Role { - switch cmd { - case "account", "whitelist", "config", "audit": +func commandRole(args []string) store.Role { + switch args[0] { + case "account": + // account list is a read-only discovery view available to agents; + // add/edit/remove mutate config and require admin. + if len(args) >= 2 && args[1] == "list" { + return store.RoleAgent + } + return store.RoleAdmin + case "whitelist", "config", "audit": return store.RoleAdmin default: // list, get, search, ack, send, doctor return store.RoleAgent @@ -135,7 +142,7 @@ func Run(args []string, out, errOut io.Writer) int { return 0 } cmd, rest := args[0], args[1:] - role := commandRole(cmd) + role := commandRole(args) switch cmd { case "list", "get", "search", "ack": return runAgent(cmd, rest, role, out, errOut) diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index f9815d3..cf38675 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -18,9 +18,9 @@ func TestRunUnknownCommand(t *testing.T) { } } -func TestRunVersionIsJSONForAgentButTextHere(t *testing.T) { - // `account list` with no DB key should fail closed with a usage/config error, - // proving the key check happens before any DB work. +func TestAccountListMissingKeyFailsClosedAsJSON(t *testing.T) { + // `account list` is an agent command: with no DB key it fails closed before + // any DB work, emitting a JSON config-error envelope that names EMCLI_KEY. var out, errOut bytes.Buffer t.Setenv("EMCLI_KEY", "") t.Setenv("EMCLI_ADMIN_KEY", "") @@ -28,8 +28,15 @@ func TestRunVersionIsJSONForAgentButTextHere(t *testing.T) { if code == 0 { t.Fatal("missing EMCLI_KEY must fail") } - if !strings.Contains(out.String()+errOut.String(), "EMCLI_ADMIN_KEY") { - t.Fatalf("should mention EMCLI_ADMIN_KEY, got out=%q err=%q", out.String(), errOut.String()) + var env map[string]any + if err := json.Unmarshal(out.Bytes(), &env); err != nil { + t.Fatalf("agent account list error must be JSON, got out=%q err=%q", out.String(), errOut.String()) + } + if env["error"] != true { + t.Fatalf("want error envelope: %v", env) + } + if !strings.Contains(out.String(), "EMCLI_KEY") { + t.Fatalf("should name the missing EMCLI_KEY, got %q", out.String()) } } diff --git a/internal/cli/security_invariant_test.go b/internal/cli/security_invariant_test.go index 824fe20..8ae8794 100644 --- a/internal/cli/security_invariant_test.go +++ b/internal/cli/security_invariant_test.go @@ -49,7 +49,7 @@ func TestAgentKeyCannotRunAdminCommands(t *testing.T) { before := dbBytes(t, db) adminAttempts := [][]string{ - {"account", "list"}, + {"account", "add", "--name", "x", "--imap-host", "h", "--username", "u@x.com"}, {"config", "set", "audit_retention_days", "30"}, {"audit"}, }