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) } }