feat(cli): agent-readable account list (reduced JSON view)
account list now routes to the agent role; an agent (EMCLI_KEY only) gets a JSON envelope of name/from/can_send, while the admin keeps the full text table. account add/edit/remove stay admin-only. Also emit the agent path's missing-key/open failure as a JSON Failure envelope (per spec), and update the stale run_test case that asserted the old admin-only behavior. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
+30
-2
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-4
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user