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.
This commit is contained in:
@@ -0,0 +1,448 @@
|
||||
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")
|
||||
}
|
||||
Reference in New Issue
Block a user