Add the thin CLI: login, projects, secrets list, get

Read-path commands over the library. Credentials never come from flags
or arguments; get prints the bare value for $(...) plumbing and nothing
else ever touches stdout; stderr carries only redacted diagnostics. Exit
codes mirror keyproxy (0 ok, 1 usage/config, 2 auth or resolution
failure). CLI tests drive the fake server through a real 0600 env file.
This commit is contained in:
2026-08-29 00:08:38 -05:00
parent a9b08b96c4
commit edf056eb9f
3 changed files with 436 additions and 0 deletions
+236
View File
@@ -0,0 +1,236 @@
package cli
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"git.knownelement.com/ukrrs/mopac-bitwarden-go/internal/fakesm"
)
// Every CLI test runs against the in-process fake Secrets Manager through
// a real 0600 env file, exercising the full path: config load -> auth ->
// decrypt -> output. stderr output is asserted secret-free.
type env struct {
stdout, stderr bytes.Buffer
srv *fakesm.Server
_cred string
}
func (e *env) run(t *testing.T, args ...string) int {
t.Helper()
e.stdout.Reset()
e.stderr.Reset()
return Run(args, &e.stdout, &e.stderr)
}
// redaction asserts stderr never leaked the fake's material. stdout is
// checked too EXCEPT for `get`, whose whole purpose is printing one
// value (the per-command tests pin the exact stdout).
func (e *env) redaction(t *testing.T) {
t.Helper()
for _, forbidden := range e.forbidden() {
if strings.Contains(e.stderr.String(), forbidden) {
t.Fatalf("stderr leaks secret material (%q...): %q", forbidden[:min(8, len(forbidden))], e.stderr.String())
}
}
}
// redactionFull additionally asserts stdout carries no material (login,
// listings, error paths).
func (e *env) redactionFull(t *testing.T) {
t.Helper()
e.redaction(t)
for _, forbidden := range e.forbidden() {
if strings.Contains(e.stdout.String(), forbidden) {
t.Fatalf("stdout leaks secret material (%q...): %q", forbidden[:min(8, len(forbidden))], e.stdout.String())
}
}
}
func (e *env) forbidden() []string {
reds := []string{
e.srv.ClientSecret,
e.srv.LastAccessToken(),
e.srv.LastRefreshToken(),
e.srv.Secrets[0].Value,
e.srv.Secrets[1].Value,
}
out := make([]string, 0, len(reds))
for _, r := range reds {
if r != "" {
out = append(out, r)
}
}
return out
}
func newEnv(t *testing.T) *env {
t.Helper()
srv, cred := fakesm.NewServer()
srv.Start()
t.Cleanup(srv.Close)
return &env{srv: srv, _cred: cred}
}
// writeEnvFile writes the 0600 credential file and points BITWARDENGO_CONFIG at it.
func (e *env) writeEnvFile(t *testing.T) {
t.Helper()
t.Setenv("BITWARDENGO_CONFIG", "")
os.Unsetenv("BITWARDENGO_CONFIG")
for _, k := range []string{"BW_SERVER_URL", "BW_ACCESS_TOKEN", "BW_CLIENTID", "BW_CLIENTSECRET"} {
t.Setenv(k, "")
os.Unsetenv(k)
}
path := filepath.Join(t.TempDir(), "env")
content := fmt.Sprintf("BW_SERVER_URL=%s\nBW_ACCESS_TOKEN=%s\n", e.srv.BaseURL(), e._cred)
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("BITWARDENGO_CONFIG", path)
}
func TestCLILogin(t *testing.T) {
e := newEnv(t)
e.writeEnvFile(t)
code := e.run(t, "login")
e.redactionFull(t)
if code != 0 {
t.Fatalf("login exit %d, stderr=%s", code, e.stderr.String())
}
out := e.stdout.String()
if !strings.Contains(out, "authenticated: account "+e.srv.ClientID) {
t.Fatalf("login output: %q", out)
}
if !strings.Contains(out, "expires") {
t.Fatalf("login output lacks expiry: %q", out)
}
}
func TestCLIGet(t *testing.T) {
e := newEnv(t)
e.writeEnvFile(t)
code := e.run(t, "get", e.srv.Secrets[0].Name)
e.redaction(t)
if code != 0 {
t.Fatalf("get exit %d, stderr=%s", code, e.stderr.String())
}
if got := e.stdout.String(); got != e.srv.Secrets[0].Value {
t.Fatalf("get output %q != value (bare, no newline enforced)", got)
}
if e.stderr.Len() != 0 {
t.Fatalf("get wrote to stderr: %q", e.stderr.String())
}
}
func TestCLIGetMissingSecret(t *testing.T) {
e := newEnv(t)
e.writeEnvFile(t)
code := e.run(t, "get", "does-not-exist")
e.redaction(t)
if code != 2 {
t.Fatalf("expected exit 2, got %d", code)
}
if !strings.Contains(e.stderr.String(), "secret not found") {
t.Fatalf("stderr: %q", e.stderr.String())
}
if e.stdout.Len() != 0 {
t.Fatalf("stdout should be empty on failure: %q", e.stdout.String())
}
}
func TestCLIBadCredentials(t *testing.T) {
e := newEnv(t)
e.srv.RejectAuth = true
e.writeEnvFile(t)
code := e.run(t, "get", e.srv.Secrets[0].Name)
e.redaction(t)
if code != 2 {
t.Fatalf("expected exit 2, got %d", code)
}
if !strings.Contains(e.stderr.String(), "auth failed") {
t.Fatalf("stderr: %q", e.stderr.String())
}
e.redactionFull(t)
}
func TestCLILists(t *testing.T) {
e := newEnv(t)
e.writeEnvFile(t)
if code := e.run(t, "projects"); code != 0 {
t.Fatalf("projects exit %d: %s", code, e.stderr.String())
}
e.redaction(t)
if !strings.Contains(e.stdout.String(), e.srv.Projects[0].ID+" harness") {
t.Fatalf("projects output: %q", e.stdout.String())
}
if code := e.run(t, "secrets", "list"); code != 0 {
t.Fatalf("secrets list exit %d: %s", code, e.stderr.String())
}
e.redaction(t)
out := e.stdout.String()
if !strings.Contains(out, e.srv.Secrets[0].ID+" redmine-api-key") || !strings.Contains(out, e.srv.Secrets[1].ID+" litellm-key") {
t.Fatalf("secrets list output: %q", out)
}
}
func TestCLIMissingCredentials(t *testing.T) {
e := newEnv(t)
t.Setenv("BITWARDENGO_CONFIG", filepath.Join(t.TempDir(), "nothing-here"))
for _, k := range []string{"BW_SERVER_URL", "BW_ACCESS_TOKEN", "BW_CLIENTID", "BW_CLIENTSECRET"} {
t.Setenv(k, "")
os.Unsetenv(k)
}
code := e.run(t, "login")
if code != 2 {
t.Fatalf("expected exit 2 for auth failure path, got %d", code)
}
if !strings.Contains(e.stderr.String(), "no credentials") {
t.Fatalf("stderr: %q", e.stderr.String())
}
e.redactionFull(t)
}
func TestCLIRefusesLooseEnvFile(t *testing.T) {
e := newEnv(t)
for _, k := range []string{"BW_SERVER_URL", "BW_ACCESS_TOKEN", "BW_CLIENTID", "BW_CLIENTSECRET", "BITWARDENGO_CONFIG"} {
t.Setenv(k, "")
os.Unsetenv(k)
}
path := filepath.Join(t.TempDir(), "env")
_ = os.WriteFile(path, []byte("BW_ACCESS_TOKEN=x\n"), 0o644)
code := e.run(t, "login", "-config", path)
if code != 2 {
t.Fatalf("expected exit 2, got %d", code)
}
if !strings.Contains(e.stderr.String(), "insecure mode") {
t.Fatalf("stderr: %q", e.stderr.String())
}
e.redactionFull(t)
}
func TestCLIUsage(t *testing.T) {
e := newEnv(t)
if code := e.run(t, "help"); code != 0 || !strings.Contains(e.stdout.String(), "Usage:") {
t.Fatalf("help exit %d", code)
}
if code := e.run(t); code != 1 {
t.Fatalf("no args exit %d", code)
}
if code := e.run(t, "bogus"); code != 1 {
t.Fatalf("unknown command exit %d", code)
}
if code := e.run(t, "get"); code != 1 {
t.Fatalf("get without key exit %d", code)
}
if code := e.run(t, "secrets"); code != 1 {
t.Fatalf("bare secrets exit %d", code)
}
if code := e.run(t, "secrets", "delete"); code != 1 {
t.Fatalf("secrets delete exit %d", code)
}
}