feat(cli): two-key role routing + init bootstrap
openStore(role) selects the DEK wrap slot; admin commands require EMCLI_ADMIN_KEY (admin slot only, no agent fallback); init writes both slots from both keys. Test helpers seed the wrap slots. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,7 +21,7 @@ func runAccount(args []string, out, errOut io.Writer) int {
|
|||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
sub, rest := args[0], args[1:]
|
sub, rest := args[0], args[1:]
|
||||||
st, err := openStore()
|
st, err := openStore(store.RoleAdmin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
||||||
return 1
|
return 1
|
||||||
@@ -204,7 +204,7 @@ func runConfig(args []string, out, errOut io.Writer) int {
|
|||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
sub, key := args[0], args[1]
|
sub, key := args[0], args[1]
|
||||||
st, err := openStore()
|
st, err := openStore(store.RoleAdmin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
||||||
return 1
|
return 1
|
||||||
@@ -262,7 +262,7 @@ func runAudit(args []string, out, errOut io.Writer) int {
|
|||||||
if err := fs.Parse(args[1:]); err != nil {
|
if err := fs.Parse(args[1:]); err != nil {
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
st, err := openStore()
|
st, err := openStore(store.RoleAdmin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
||||||
return 1
|
return 1
|
||||||
@@ -301,7 +301,7 @@ func runWhitelist(args []string, out, errOut io.Writer) int {
|
|||||||
fmt.Fprintln(errOut, "--account is required")
|
fmt.Fprintln(errOut, "--account is required")
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
st, err := openStore()
|
st, err := openStore(store.RoleAdmin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
@@ -7,15 +7,28 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.dcglab.co.uk/steve/emcli/internal/crypto"
|
||||||
"git.dcglab.co.uk/steve/emcli/internal/store"
|
"git.dcglab.co.uk/steve/emcli/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// adminEnv points EMCLI_KEY/EMCLI_DB at a fresh temp DB and returns its path.
|
// adminEnv points both keys + EMCLI_DB at a fresh, initialized temp DB.
|
||||||
func adminEnv(t *testing.T) string {
|
func adminEnv(t *testing.T) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
db := filepath.Join(t.TempDir(), "emcli.db")
|
db := filepath.Join(t.TempDir(), "emcli.db")
|
||||||
t.Setenv("EMCLI_KEY", b64Key())
|
t.Setenv("EMCLI_ADMIN_KEY", b64Key())
|
||||||
|
t.Setenv("EMCLI_KEY", b64AgentKey())
|
||||||
t.Setenv("EMCLI_DB", db)
|
t.Setenv("EMCLI_DB", db)
|
||||||
|
|
||||||
|
st, err := store.Open(db)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open: %v", err)
|
||||||
|
}
|
||||||
|
adminKey, _ := crypto.AdminKeyFromEnv()
|
||||||
|
agentKey, _ := crypto.AgentKeyFromEnv()
|
||||||
|
if err := st.InitKeys(adminKey, agentKey); err != nil {
|
||||||
|
t.Fatalf("InitKeys: %v", err)
|
||||||
|
}
|
||||||
|
st.Close()
|
||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,11 +88,16 @@ func TestAccountEditPartialPreservesOtherFields(t *testing.T) {
|
|||||||
"--smtp-host", "smtp.x.com", "--smtp-port", "587", "--smtp-security", "starttls"); code != 0 {
|
"--smtp-host", "smtp.x.com", "--smtp-port", "587", "--smtp-security", "starttls"); code != 0 {
|
||||||
t.Fatalf("edit failed: %s", e)
|
t.Fatalf("edit failed: %s", e)
|
||||||
}
|
}
|
||||||
st, err := store.Open(db, mustKey())
|
st, err := store.Open(db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("open: %v", err)
|
t.Fatalf("open: %v", err)
|
||||||
}
|
}
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
adminKey, _ := crypto.AdminKeyFromEnv()
|
||||||
|
agentKey, _ := crypto.AgentKeyFromEnv()
|
||||||
|
if err := st.Unlock(store.RoleAdmin, adminKey, agentKey); err != nil {
|
||||||
|
t.Fatalf("Unlock: %v", err)
|
||||||
|
}
|
||||||
got, err := st.GetAccount("ed")
|
got, err := st.GetAccount("ed")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetAccount: %v", err)
|
t.Fatalf("GetAccount: %v", err)
|
||||||
@@ -93,11 +111,14 @@ func TestAccountEditPartialPreservesOtherFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAuditListCoreRenders(t *testing.T) {
|
func TestAuditListCoreRenders(t *testing.T) {
|
||||||
st, err := store.Open(filepath.Join(t.TempDir(), "e.db"), testKey())
|
st, err := store.Open(filepath.Join(t.TempDir(), "e.db"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("open: %v", err)
|
t.Fatalf("open: %v", err)
|
||||||
}
|
}
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
if err := st.InitKeys(testKey(), testKey()); err != nil {
|
||||||
|
t.Fatalf("InitKeys: %v", err)
|
||||||
|
}
|
||||||
now := time.Date(2026, 6, 22, 0, 0, 0, 0, time.UTC)
|
now := time.Date(2026, 6, 22, 0, 0, 0, 0, time.UTC)
|
||||||
_ = st.Audit(now, store.AuditEntry{Account: "a", Action: "list", Target: "INBOX", Result: "allowed"})
|
_ = st.Audit(now, store.AuditEntry{Account: "a", Action: "list", Target: "INBOX", Result: "allowed"})
|
||||||
_ = st.Audit(now, store.AuditEntry{Account: "a", Action: "send", Target: "x@y.com", Result: "blocked", Reason: "whitelist_out"})
|
_ = st.Audit(now, store.AuditEntry{Account: "a", Action: "send", Target: "x@y.com", Result: "blocked", Reason: "whitelist_out"})
|
||||||
@@ -111,5 +132,3 @@ func TestAuditListCoreRenders(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// mustKey decodes the same 32-zero-byte key used by b64Key for store reopen.
|
|
||||||
func mustKey() []byte { return make([]byte, 32) }
|
|
||||||
|
|||||||
@@ -58,10 +58,13 @@ func testKey() []byte {
|
|||||||
|
|
||||||
func newDeps(t *testing.T, fm *fakeMailer) (Deps, *bytes.Buffer) {
|
func newDeps(t *testing.T, fm *fakeMailer) (Deps, *bytes.Buffer) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
st, err := store.Open(filepath.Join(t.TempDir(), "e.db"), testKey())
|
st, err := store.Open(filepath.Join(t.TempDir(), "e.db"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("store: %v", err)
|
t.Fatalf("store: %v", err)
|
||||||
}
|
}
|
||||||
|
if err := st.InitKeys(testKey(), testKey()); err != nil {
|
||||||
|
t.Fatalf("InitKeys: %v", err)
|
||||||
|
}
|
||||||
t.Cleanup(func() { st.Close() })
|
t.Cleanup(func() { st.Close() })
|
||||||
_, err = st.AddAccount(store.Account{
|
_, err = st.AddAccount(store.Account{
|
||||||
Name: "work", Mode: "RO", IMAPHost: "h", IMAPPort: 993, IMAPSecurity: "tls",
|
Name: "work", Mode: "RO", IMAPHost: "h", IMAPPort: 993, IMAPSecurity: "tls",
|
||||||
|
|||||||
@@ -11,10 +11,13 @@ import (
|
|||||||
|
|
||||||
func doctorDeps(t *testing.T, accounts []store.Account, imap, smtp func(store.Account) error) (Deps, *[]byte) {
|
func doctorDeps(t *testing.T, accounts []store.Account, imap, smtp func(store.Account) error) (Deps, *[]byte) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
st, err := store.Open(filepath.Join(t.TempDir(), "e.db"), testKey())
|
st, err := store.Open(filepath.Join(t.TempDir(), "e.db"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("store: %v", err)
|
t.Fatalf("store: %v", err)
|
||||||
}
|
}
|
||||||
|
if err := st.InitKeys(testKey(), testKey()); err != nil {
|
||||||
|
t.Fatalf("InitKeys: %v", err)
|
||||||
|
}
|
||||||
t.Cleanup(func() { st.Close() })
|
t.Cleanup(func() { st.Close() })
|
||||||
for _, a := range accounts {
|
for _, a := range accounts {
|
||||||
if _, err := st.AddAccount(a); err != nil {
|
if _, err := st.AddAccount(a); err != nil {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
"git.dcglab.co.uk/steve/emcli/internal/crypto"
|
||||||
"git.dcglab.co.uk/steve/emcli/internal/store"
|
"git.dcglab.co.uk/steve/emcli/internal/store"
|
||||||
"git.dcglab.co.uk/steve/emcli/internal/tui"
|
"git.dcglab.co.uk/steve/emcli/internal/tui"
|
||||||
)
|
)
|
||||||
@@ -70,19 +71,38 @@ func editInteractive(st *store.Store, name string, out, errOut io.Writer) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// runInit creates/opens the DB and adds the first account via the TUI form,
|
// runInit creates/opens the DB, writes both DEK wrap slots, and adds the first
|
||||||
// seeding a default audit retention if unset.
|
// account via the TUI form, seeding a default audit retention if unset.
|
||||||
func runInit(args []string, out, errOut io.Writer) int {
|
func runInit(args []string, out, errOut io.Writer) int {
|
||||||
if len(args) > 0 && helpRequested(args[0]) {
|
if len(args) > 0 && helpRequested(args[0]) {
|
||||||
printCmdUsage(out, "init")
|
printCmdUsage(out, "init")
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
st, err := openStore()
|
adminKey, err := crypto.AdminKeyFromEnv()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
agentKey, err := crypto.AgentKeyFromEnv()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
path, err := store.DefaultDBPath()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
st, err := store.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
if err := st.InitKeys(adminKey, agentKey); err != nil {
|
||||||
|
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
if _, err := st.GetSetting("audit_retention_days"); err != nil {
|
if _, err := st.GetSetting("audit_retention_days"); err != nil {
|
||||||
_ = st.SetSetting("audit_retention_days", "90")
|
_ = st.SetSetting("audit_retention_days", "90")
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.dcglab.co.uk/steve/emcli/internal/crypto"
|
||||||
|
"git.dcglab.co.uk/steve/emcli/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCommandRole(t *testing.T) {
|
||||||
|
admin := []string{"account", "whitelist", "config", "audit"}
|
||||||
|
agent := []string{"list", "get", "search", "ack", "send", "doctor"}
|
||||||
|
for _, c := range admin {
|
||||||
|
if commandRole(c) != store.RoleAdmin {
|
||||||
|
t.Errorf("%s should be admin", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, c := range agent {
|
||||||
|
if commandRole(c) != store.RoleAgent {
|
||||||
|
t.Errorf("%s should be agent", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentCommandWorksWithOnlyAdminKey(t *testing.T) {
|
||||||
|
// A human holding only the admin key can still run agent commands
|
||||||
|
// (admin is a superset → agent-role unlock falls back to the admin slot).
|
||||||
|
db := filepath.Join(t.TempDir(), "emcli.db")
|
||||||
|
t.Setenv("EMCLI_ADMIN_KEY", b64Key())
|
||||||
|
t.Setenv("EMCLI_KEY", b64AgentKey())
|
||||||
|
t.Setenv("EMCLI_DB", db)
|
||||||
|
st, _ := store.Open(db)
|
||||||
|
ak, _ := crypto.AdminKeyFromEnv()
|
||||||
|
gk, _ := crypto.AgentKeyFromEnv()
|
||||||
|
st.InitKeys(ak, gk)
|
||||||
|
st.Close()
|
||||||
|
|
||||||
|
// Only the admin key now; agent command must still open the store.
|
||||||
|
t.Setenv("EMCLI_KEY", "")
|
||||||
|
s2, err := openStore(store.RoleAgent)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("agent role with only admin key should open: %v", err)
|
||||||
|
}
|
||||||
|
s2.Close()
|
||||||
|
}
|
||||||
+40
-9
@@ -25,17 +25,48 @@ func realMailer(acc store.Account) (Mailer, error) {
|
|||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// openStore loads the key and opens the DB, returning a human-readable error string.
|
// commandRole maps a command to the privilege it requires. Admin commands
|
||||||
func openStore() (*store.Store, error) {
|
// mutate configuration or expose oversight data; everything else is agent.
|
||||||
key, err := crypto.KeyFromEnv()
|
func commandRole(cmd string) store.Role {
|
||||||
if err != nil {
|
switch cmd {
|
||||||
return nil, err
|
case "account", "whitelist", "config", "audit":
|
||||||
|
return store.RoleAdmin
|
||||||
|
default: // list, get, search, ack, send, doctor
|
||||||
|
return store.RoleAgent
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// openStore resolves the keys for the role, opens the DB, and unlocks the DEK.
|
||||||
|
// Admin commands require EMCLI_ADMIN_KEY and unlock the admin slot only; agent
|
||||||
|
// commands use EMCLI_KEY (falling back to the admin key if that is all there is).
|
||||||
|
func openStore(role store.Role) (*store.Store, error) {
|
||||||
|
adminKey, adminErr := crypto.AdminKeyFromEnv()
|
||||||
|
agentKey, agentErr := crypto.AgentKeyFromEnv()
|
||||||
|
|
||||||
|
switch role {
|
||||||
|
case store.RoleAdmin:
|
||||||
|
if adminErr != nil {
|
||||||
|
return nil, fmt.Errorf("this command requires EMCLI_ADMIN_KEY (admin privilege)")
|
||||||
|
}
|
||||||
|
case store.RoleAgent:
|
||||||
|
if agentErr != nil && adminErr != nil {
|
||||||
|
return nil, agentErr // "EMCLI_KEY is not set"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
path, err := store.DefaultDBPath()
|
path, err := store.DefaultDBPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return store.Open(path, key)
|
st, err := store.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := st.Unlock(role, adminKey, agentKey); err != nil {
|
||||||
|
st.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return st, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func realSender(acc store.Account, m mail.OutgoingMessage) error {
|
func realSender(acc store.Account, m mail.OutgoingMessage) error {
|
||||||
@@ -79,7 +110,7 @@ func runDoctor(args []string, out, errOut io.Writer) int {
|
|||||||
}
|
}
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
st, err := openStore()
|
st, err := openStore(store.RoleAgent)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
||||||
return 1
|
return 1
|
||||||
@@ -159,7 +190,7 @@ func runAgent(cmd string, args []string, out, errOut io.Writer) int {
|
|||||||
_ = Failure(CodeUsage, "--account is required").Write(out)
|
_ = Failure(CodeUsage, "--account is required").Write(out)
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
st, err := openStore()
|
st, err := openStore(store.RoleAgent)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = Failure(CodeConfig, err.Error()).Write(out)
|
_ = Failure(CodeConfig, err.Error()).Write(out)
|
||||||
return 1
|
return 1
|
||||||
@@ -249,7 +280,7 @@ func runSend(args []string, out, errOut io.Writer) int {
|
|||||||
_ = Failure(CodeUsage, "--account is required").Write(out)
|
_ = Failure(CodeUsage, "--account is required").Write(out)
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
st, err := openStore()
|
st, err := openStore(store.RoleAgent)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = Failure(CodeConfig, err.Error()).Write(out)
|
_ = Failure(CodeConfig, err.Error()).Write(out)
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
@@ -23,12 +23,13 @@ func TestRunVersionIsJSONForAgentButTextHere(t *testing.T) {
|
|||||||
// proving the key check happens before any DB work.
|
// proving the key check happens before any DB work.
|
||||||
var out, errOut bytes.Buffer
|
var out, errOut bytes.Buffer
|
||||||
t.Setenv("EMCLI_KEY", "")
|
t.Setenv("EMCLI_KEY", "")
|
||||||
|
t.Setenv("EMCLI_ADMIN_KEY", "")
|
||||||
code := Run([]string{"account", "list"}, &out, &errOut)
|
code := Run([]string{"account", "list"}, &out, &errOut)
|
||||||
if code == 0 {
|
if code == 0 {
|
||||||
t.Fatal("missing EMCLI_KEY must fail")
|
t.Fatal("missing EMCLI_KEY must fail")
|
||||||
}
|
}
|
||||||
if !strings.Contains(out.String()+errOut.String(), "EMCLI_KEY") {
|
if !strings.Contains(out.String()+errOut.String(), "EMCLI_ADMIN_KEY") {
|
||||||
t.Fatalf("should mention EMCLI_KEY, got out=%q err=%q", out.String(), errOut.String())
|
t.Fatalf("should mention EMCLI_ADMIN_KEY, got out=%q err=%q", out.String(), errOut.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,3 +55,8 @@ func b64Key() string {
|
|||||||
// 32 zero bytes, base64.
|
// 32 zero bytes, base64.
|
||||||
return "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
return "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func b64AgentKey() string {
|
||||||
|
// 32 bytes of 0x01, base64 — distinct from b64Key so slot mix-ups surface.
|
||||||
|
return "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,10 +13,13 @@ import (
|
|||||||
// mailer (for reply-to). The named account is created per the supplied template.
|
// mailer (for reply-to). The named account is created per the supplied template.
|
||||||
func sendDeps(t *testing.T, acc store.Account, fm *fakeMailer) (Deps, *[]mail.OutgoingMessage, *[]byte) {
|
func sendDeps(t *testing.T, acc store.Account, fm *fakeMailer) (Deps, *[]mail.OutgoingMessage, *[]byte) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
st, err := store.Open(filepath.Join(t.TempDir(), "e.db"), testKey())
|
st, err := store.Open(filepath.Join(t.TempDir(), "e.db"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("store: %v", err)
|
t.Fatalf("store: %v", err)
|
||||||
}
|
}
|
||||||
|
if err := st.InitKeys(testKey(), testKey()); err != nil {
|
||||||
|
t.Fatalf("InitKeys: %v", err)
|
||||||
|
}
|
||||||
t.Cleanup(func() { st.Close() })
|
t.Cleanup(func() { st.Close() })
|
||||||
if _, err := st.AddAccount(acc); err != nil {
|
if _, err := st.AddAccount(acc); err != nil {
|
||||||
t.Fatalf("AddAccount: %v", err)
|
t.Fatalf("AddAccount: %v", err)
|
||||||
|
|||||||
Reference in New Issue
Block a user