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..:"} 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") }