Files
mrcharles c94c0b1de4 Add the Secrets Manager REST client with a fake-server test suite
Public surface Authenticate/GetSecret/ListSecrets/ListProjects: OAuth
client_credentials against /identity/connect/token (with the
encrypted_payload organization-key unwrap), refresh-before-expiry, and
bearer reads under /api with in-memory decryption. Errors are fixed
reason enums that can never embed material. Everything is tested against
an in-process fake Secrets Manager speaking the same protocol and crypto
(auth failure, expiry, refresh, missing secrets, malformed payloads,
tampered MACs, plaintext mode, and redaction sweeps over every error
path); the real vault is never contacted.
2026-08-29 00:08:31 -05:00

449 lines
12 KiB
Go

package bitwarden_test
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"time"
bw "git.knownelement.com/ukrrs/mopac-bitwarden-go"
"git.knownelement.com/ukrrs/mopac-bitwarden-go/internal/fakesm"
)
// The entire suite runs against the in-process fake Secrets Manager
// (internal/fakesm). No test in this repo ever contacts a real vault.
// fixture is one configured fake plus the redaction set: strings that must
// NEVER appear in any error message produced by the library.
type fixture struct {
srv *fakesm.Server
cred string
reds []string // forbidden substrings for errors
}
func newFixture(t *testing.T) *fixture {
t.Helper()
srv, cred := fakesm.NewServer()
srv.Start()
t.Cleanup(srv.Close)
s := srv.Secrets[0]
return &fixture{
srv: srv,
cred: cred,
reds: []string{
srv.ClientSecret,
srv.LastAccessToken(), // empty until minted; live values added per-assert
s.Value,
srv.Secrets[1].Value,
},
}
}
// forbid asserts err (when non-nil) leaks nothing from the redaction set.
func (f *fixture) forbid(t *testing.T, err error) {
t.Helper()
if err == nil {
return
}
msg := err.Error()
reds := append([]string{}, f.reds...)
if tok := f.srv.LastAccessToken(); tok != "" {
reds = append(reds, tok)
}
if rt := f.srv.LastRefreshToken(); rt != "" {
reds = append(reds, rt)
}
for _, r := range reds {
if r != "" && strings.Contains(msg, r) {
t.Fatalf("error leaks secret material (%q): %v", mask(r), err)
}
}
}
// mask keeps even the assertion output free of material.
func mask(s string) string {
if len(s) > 6 {
return s[:6] + "..."
}
return "***"
}
func (f *fixture) creds() bw.Credentials {
return bw.Credentials{BaseURL: f.srv.BaseURL(), AccessToken: f.cred}
}
func TestAuthenticateAndGetSecretHappyPath(t *testing.T) {
f := newFixture(t)
ctx := context.Background()
tok, err := bw.Authenticate(ctx, f.creds())
if err != nil {
t.Fatalf("authenticate: %v", err)
}
defer tok.Zero()
if tok.Organization != f.srv.OrgID {
t.Fatalf("organization claim: %s", tok.Organization)
}
if f.srv.TokenCalls != 1 {
t.Fatalf("token calls: %d", f.srv.TokenCalls)
}
for _, sec := range f.srv.Secrets {
got, err := bw.GetSecret(ctx, tok, sec.Name)
if err != nil {
t.Fatalf("get %s: %v", sec.Name, err)
}
if got != sec.Value {
t.Fatalf("get %s: wrong value", sec.Name)
}
}
if f.srv.RefreshCalls != 0 {
t.Fatalf("refresh on a fresh token: %d", f.srv.RefreshCalls)
}
}
func TestGetSecretByID(t *testing.T) {
f := newFixture(t)
ctx := context.Background()
tok, _ := bw.Authenticate(ctx, f.creds())
defer tok.Zero()
got, err := bw.GetSecret(ctx, tok, f.srv.Secrets[0].ID)
if err != nil {
t.Fatalf("get by id: %v", err)
}
if got != f.srv.Secrets[0].Value {
t.Fatal("get by id: wrong value")
}
}
func TestListSecretsAndProjects(t *testing.T) {
f := newFixture(t)
ctx := context.Background()
tok, _ := bw.Authenticate(ctx, f.creds())
defer tok.Zero()
secrets, err := bw.ListSecrets(ctx, tok)
if err != nil {
t.Fatalf("list secrets: %v", err)
}
if len(secrets) != 2 || secrets[0].Name != "redmine-api-key" || secrets[1].Name != "litellm-key" {
t.Fatalf("list secrets: %+v", secrets)
}
for _, s := range secrets {
if strings.Contains(s.Name, "2.") && strings.Count(s.Name, ".") >= 3 {
t.Fatalf("list secrets returned ciphertext: %q", s.Name)
}
}
projects, err := bw.ListProjects(ctx, tok)
if err != nil {
t.Fatalf("list projects: %v", err)
}
if len(projects) != 1 || projects[0].Name != "harness" {
t.Fatalf("list projects: %+v", projects)
}
}
func TestFailureTable(t *testing.T) {
cases := []struct {
name string
mutate func(*fakesm.Server, *fixture)
call func(context.Context, *fixture) error
wantErr error
}{
{
name: "wrong client secret",
mutate: func(s *fakesm.Server, f *fixture) {
f.cred = strings.Replace(f.cred, s.ClientSecret, "wrong-secret-entirely", 1)
},
call: func(ctx context.Context, f *fixture) error { _, err := bw.Authenticate(ctx, f.creds()); return err },
wantErr: bw.ErrAuthFailed,
},
{
name: "server 500 on token endpoint",
mutate: func(s *fakesm.Server, f *fixture) { s.AuthStatusOverride = 500 },
call: func(ctx context.Context, f *fixture) error { _, err := bw.Authenticate(ctx, f.creds()); return err },
wantErr: bw.ErrServer,
},
{
name: "malformed token body",
mutate: func(s *fakesm.Server, f *fixture) { s.MalformedTokenBody = true },
call: func(ctx context.Context, f *fixture) error { _, err := bw.Authenticate(ctx, f.creds()); return err },
wantErr: bw.ErrMalformedResponse,
},
{
name: "unreachable server",
mutate: func(s *fakesm.Server, f *fixture) {
s.Close()
},
call: func(ctx context.Context, f *fixture) error { _, err := bw.Authenticate(ctx, f.creds()); return err },
wantErr: bw.ErrUnreachable,
},
{
name: "missing secret name",
call: func(ctx context.Context, f *fixture) error {
tok, err := bw.Authenticate(ctx, f.creds())
if err != nil {
return err
}
defer tok.Zero()
_, err = bw.GetSecret(ctx, tok, "no-such-secret")
return err
},
wantErr: bw.ErrSecretNotFound,
},
{
name: "missing secret by id",
call: func(ctx context.Context, f *fixture) error {
tok, err := bw.Authenticate(ctx, f.creds())
if err != nil {
return err
}
defer tok.Zero()
_, err = bw.GetSecret(ctx, tok, "00000000-0000-4000-8000-00000000dead")
return err
},
wantErr: bw.ErrSecretNotFound,
},
{
name: "malformed secrets list",
mutate: func(s *fakesm.Server, f *fixture) { s.MalformedListBody = true },
wantErr: bw.ErrMalformedResponse,
},
{
name: "tampered value mac",
mutate: func(s *fakesm.Server, f *fixture) { s.TamperSecretMAC = true },
wantErr: bw.ErrDecrypt,
},
{
name: "api bearer rejected",
mutate: func(s *fakesm.Server, f *fixture) { s.RejectBearer = true },
wantErr: bw.ErrAuthFailed,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := newFixture(t)
if tc.mutate != nil {
tc.mutate(f.srv, f)
}
call := tc.call
if call == nil {
call = func(ctx context.Context, f *fixture) error {
tok, err := bw.Authenticate(ctx, f.creds())
if err != nil {
return err
}
defer tok.Zero()
_, err = bw.GetSecret(ctx, tok, f.srv.Secrets[0].Name)
return err
}
}
err := call(context.Background(), f)
f.forbid(t, err)
if err == nil {
t.Fatal("expected error, got none")
}
if !errors.Is(err, tc.wantErr) {
t.Fatalf("error class: got %v, want %v", err, tc.wantErr)
}
})
}
}
func TestDropEncryptedPayloadFailsClosed(t *testing.T) {
f := newFixture(t)
f.srv.DropEncryptedPayload = true
tok, err := bw.Authenticate(context.Background(), f.creds())
f.forbid(t, err)
if err != nil {
t.Fatalf("authenticate should succeed without payload: %v", err)
}
defer tok.Zero()
_, err = bw.GetSecret(context.Background(), tok, f.srv.Secrets[0].Name)
f.forbid(t, err)
if !errors.Is(err, bw.ErrDecrypt) {
t.Fatalf("expected decrypt failure without org key, got %v", err)
}
}
func TestSplitCredentialsWithoutKeyCannotDecrypt(t *testing.T) {
// Split-form credentials on an encrypting server: login works (the
// token endpoint itself needs no local key) but the encrypted payload
// cannot be unwrapped, so login must fail loudly rather than leak
// ciphertext downstream.
f := newFixture(t)
_, err := bw.Authenticate(context.Background(), bw.Credentials{
BaseURL: f.srv.BaseURL(),
ClientID: f.srv.ClientID,
ClientSecret: f.srv.ClientSecret,
})
f.forbid(t, err)
if !errors.Is(err, bw.ErrDecrypt) {
t.Fatalf("expected decrypt error, got %v", err)
}
}
func TestPlaintextServerMode(t *testing.T) {
// A server that returns unencrypted payloads (plaintext gateway or
// minimal test double) works with split credentials and with access
// tokens alike; nothing encrypted ever reaches the caller.
f := newFixture(t)
f.srv.OrgKey = nil
ctx := context.Background()
tok, err := bw.Authenticate(ctx, bw.Credentials{
BaseURL: f.srv.BaseURL(),
ClientID: f.srv.ClientID,
ClientSecret: f.srv.ClientSecret,
})
if err != nil {
t.Fatalf("authenticate: %v", err)
}
defer tok.Zero()
got, err := bw.GetSecret(ctx, tok, f.srv.Secrets[0].Name)
f.forbid(t, err)
if err != nil {
t.Fatalf("get: %v", err)
}
if got != f.srv.Secrets[0].Value {
t.Fatal("plaintext get: wrong value")
}
}
func TestRefreshBeforeExpiry(t *testing.T) {
f := newFixture(t)
f.srv.TokenTTL = 2 * time.Second // inside the 30s refresh skew
ctx := context.Background()
tok, err := bw.Authenticate(ctx, f.creds())
if err != nil {
t.Fatalf("authenticate: %v", err)
}
defer tok.Zero()
if _, err := bw.GetSecret(ctx, tok, f.srv.Secrets[0].Name); err != nil {
t.Fatalf("get after near-expiry refresh: %v", err)
}
if f.srv.RefreshCalls == 0 {
t.Fatal("no refresh happened before expiry")
}
if _, err := bw.GetSecret(ctx, tok, f.srv.Secrets[0].Name); err != nil {
t.Fatalf("second get: %v", err)
}
if tok.AccessToken == "" {
t.Fatal("access token lost after refresh")
}
f.forbid(t, nil)
}
func TestExpiredWithoutRefreshToken(t *testing.T) {
f := newFixture(t)
ctx := context.Background()
tok, err := bw.Authenticate(ctx, f.creds())
if err != nil {
t.Fatalf("authenticate: %v", err)
}
defer tok.Zero()
tok.RefreshToken = "" // simulate a server that issued none
tok.ExpiresAt = time.Now().Add(-time.Minute)
_, err = bw.GetSecret(ctx, tok, f.srv.Secrets[0].Name)
f.forbid(t, err)
if !errors.Is(err, bw.ErrTokenExpired) {
t.Fatalf("expected token expired, got %v", err)
}
}
func TestRefreshFailureSurfaces(t *testing.T) {
f := newFixture(t)
f.srv.TokenTTL = 2 * time.Second
ctx := context.Background()
tok, err := bw.Authenticate(ctx, f.creds())
if err != nil {
t.Fatalf("authenticate: %v", err)
}
defer tok.Zero()
f.srv.RejectRefresh = true
_, err = bw.GetSecret(ctx, tok, f.srv.Secrets[0].Name)
f.forbid(t, err)
if !errors.Is(err, bw.ErrAuthFailed) {
t.Fatalf("expected auth failure from rejected refresh, got %v", err)
}
}
func TestOmittedValueInListFallsBackToByID(t *testing.T) {
f := newFixture(t)
f.srv.OmitValueInList = true
ctx := context.Background()
tok, err := bw.Authenticate(ctx, f.creds())
if err != nil {
t.Fatalf("authenticate: %v", err)
}
defer tok.Zero()
got, err := bw.GetSecret(ctx, tok, f.srv.Secrets[0].Name)
if err != nil {
t.Fatalf("get: %v", err)
}
if got != f.srv.Secrets[0].Value {
t.Fatal("fallback get: wrong value")
}
}
func TestTokenStringIsLogSafe(t *testing.T) {
f := newFixture(t)
tok, err := bw.Authenticate(context.Background(), f.creds())
if err != nil {
t.Fatalf("authenticate: %v", err)
}
defer tok.Zero()
s := tok.String()
for _, forbidden := range []string{tok.AccessToken, tok.RefreshToken, f.srv.ClientSecret} {
if forbidden != "" && strings.Contains(s, forbidden) {
t.Fatalf("Token.String leaks material: %s", s)
}
}
if !strings.Contains(s, "expires") {
t.Fatalf("Token.String lacks expiry: %s", s)
}
var nilTok *bw.Token
if _ = nilTok; nilTok.String() == "" {
t.Fatal("nil token String")
}
}
func TestInvalidCredentialsInput(t *testing.T) {
f := newFixture(t)
ctx := context.Background()
cases := []struct {
name string
creds bw.Credentials
}{
{"empty", bw.Credentials{}},
{"no base url", bw.Credentials{AccessToken: f.cred}},
{"garbage token", bw.Credentials{BaseURL: f.srv.BaseURL(), AccessToken: "not-a-token"}},
{"half split", bw.Credentials{BaseURL: f.srv.BaseURL(), ClientID: "x"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := bw.Authenticate(ctx, tc.creds)
if !errors.Is(err, bw.ErrInvalidCredentials) {
t.Fatalf("expected invalid credentials, got %v", err)
}
f.forbid(t, err)
})
}
}
func ExampleGetSecret() {
// Wired exactly as keyproxy's bitwarden backend will call it; the
// server URL here is a stand-in (tests bind the fake server).
creds := bw.Credentials{BaseURL: "https://vault.example.com", AccessToken: "0.<uuid>.<secret>:<key>"}
tok, err := bw.Authenticate(context.Background(), creds)
if err != nil {
fmt.Println("auth:", err)
return
}
defer tok.Zero()
value, err := bw.GetSecret(context.Background(), tok, "redmine-api-key")
if err != nil {
fmt.Println("get:", err)
return
}
fmt.Println(len(value), "chars")
}