Bundle M.Cloud (Coverage Audit Closure): AzureKV + GCP-SM — H-004 closed

Closes the deferred 4th sub-batch from Bundle M; Bundle M is now FULLY CLOSED across all 4 sub-batches.

Coverage:

  AzureKV:  41.2% -> 85.6%  (+44.4pp; +15.6 above 70% target)

  GCP-SM:   43.1% -> 83.4%  (+40.3pp; +13.4 above 70% target)

Engineering: rewritingTransport (custom http.RoundTripper) intercepts

the hardcoded cloud-API URLs (login.microsoftonline.com /

oauth2.googleapis.com / secretmanager.googleapis.com) and rewrites Host

to point at an httptest.Server while preserving Path + Query. For GCP,

the service-account JSON file written to t.TempDir() carries token_uri

pointing at the test server (clean override path).

azurekv_failure_test.go (~280 LoC, 13 tests):

  - getAccessToken: happy + cached-reuse + 401 + malformed JSON +

    empty-token + network-error

  - ListCertificates: happy + token-failure + 5xx + malformed +

    multi-page pagination via nextLink

  - GetCertificate: happy + 404 + malformed JSON

  - New constructor smoke

gcpsm_failure_test.go (~430 LoC, 19 tests):

  - loadServiceAccountKey: happy + file-not-found + malformed-JSON +

    bad-PEM + empty-private-key

  - getAccessToken: happy (JWT-bearer flow) + cached-reuse + 401 +

    malformed + empty-token + load-credentials-failure

  - ListSecrets: happy + token-failure + 5xx + malformed

  - AccessSecretVersion: happy + 404 + bad-base64-payload

  - Name / Type identity

Verification:

  go vet ./internal/connector/discovery/{azurekv,gcpsm}/...    clean

  gofmt -l                                                     clean

  staticcheck -checks all                                      clean (only

    pre-existing ST1005 hits in master, unrelated to Bundle M.Cloud)

  go test -short -count=1                                      PASS

  go test -race -count=1                                       PASS, 0 races

Audit deliverables:

  findings.yaml: -0011 status open -> closed with full closure_note

  gap-backlog.md: H-004 strikethrough + Bundle M.Cloud closure-log entry

  coverage-matrix.md: 2 new rows for AzureKV + GCP-SM at post-Bundle coverage

  closure-plan.md: Bundle M [~] -> [x] (all 4 sub-batches closed)

  CHANGELOG.md: [unreleased] Bundle M.Cloud entry
This commit is contained in:
shankar0123
2026-04-27 17:34:00 +00:00
parent 5d96f965bc
commit 3a84432eeb
3 changed files with 868 additions and 0 deletions
@@ -0,0 +1,388 @@
package azurekv
// Bundle M.Cloud (AzureKV portion) — Azure Key Vault discovery realclient
// failure-mode coverage. Closes finding H-004 (azurekv portion).
//
// Strategy: the existing azurekv_test.go tests Source via the KVClient
// interface using a mock; httpKVClient methods (ListCertificates,
// GetCertificate, getAccessToken) sit at 0%. Bundle M.Cloud builds a
// custom http.RoundTripper that rewrites Microsoft Azure URLs
// (login.microsoftonline.com + the configured vault URL) to a test server,
// then exercises the realclient methods end-to-end.
//
// Pattern mirrors Bundle M.F5 (httptest.Server with canned REST responses).
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/json"
"io"
"log/slog"
"math/big"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
// rewritingTransport is an http.RoundTripper that rewrites every request's
// host to the test server's host. This lets us point httpKVClient at a
// real-looking VaultURL (https://myvault.vault.azure.net) and still have
// the requests land on httptest.Server.
type rewritingTransport struct {
target *httptest.Server
}
func (rt *rewritingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// Build a new URL that targets the test server but preserves path + query.
newURL := *req.URL
newURL.Scheme = "http" // httptest is plain http
newURL.Host = rt.target.Listener.Addr().String()
newReq := req.Clone(req.Context())
newReq.URL = &newURL
newReq.Host = newURL.Host
return rt.target.Client().Transport.RoundTrip(newReq)
}
func newTestAzureClient(t *testing.T, ts *httptest.Server) *httpKVClient {
t.Helper()
httpClient := &http.Client{
Transport: &rewritingTransport{target: ts},
Timeout: 30 * time.Second,
}
return &httpKVClient{
config: Config{
VaultURL: "https://myvault.vault.azure.net",
TenantID: "tenant-id-1234",
ClientID: "client-id-1234",
ClientSecret: "client-secret-12345",
},
httpClient: httpClient,
}
}
func quietAzureLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError}))
}
// makeAzureCertCER builds a base64-encoded DER certificate suitable as the
// "cer" field in an Azure certificateBundle response.
func makeAzureCertCER(t *testing.T) string {
t.Helper()
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("gen key: %v", err)
}
tmpl := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "test.example.com"},
NotBefore: time.Now(),
NotAfter: time.Now().Add(365 * 24 * time.Hour),
}
der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv)
if err != nil {
t.Fatalf("create cert: %v", err)
}
return base64.StdEncoding.EncodeToString(der)
}
// ---------------------------------------------------------------------------
// getAccessToken
// ---------------------------------------------------------------------------
func TestAzureGetAccessToken_HappyPath(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.URL.Path, "/oauth2/v2.0/token") {
http.Error(w, "wrong path", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"access_token":"tok-abc","expires_in":3600,"token_type":"Bearer"}`)
}))
defer ts.Close()
c := newTestAzureClient(t, ts)
tok, err := c.getAccessToken(context.Background())
if err != nil {
t.Fatalf("getAccessToken: %v", err)
}
if tok != "tok-abc" {
t.Errorf("token = %q; want 'tok-abc'", tok)
}
}
func TestAzureGetAccessToken_CachedReuse(t *testing.T) {
count := atomic.Int32{}
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
count.Add(1)
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"access_token":"tok-cached","expires_in":3600,"token_type":"Bearer"}`)
}))
defer ts.Close()
c := newTestAzureClient(t, ts)
// First call hits the token endpoint.
if _, err := c.getAccessToken(context.Background()); err != nil {
t.Fatalf("first call: %v", err)
}
// Second call should reuse cache (5-min buffer not expired).
if _, err := c.getAccessToken(context.Background()); err != nil {
t.Fatalf("second call: %v", err)
}
if count.Load() != 1 {
t.Errorf("token endpoint hit %d times; want exactly 1 (cache miss)", count.Load())
}
}
func TestAzureGetAccessToken_4xx(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = io.WriteString(w, `{"error":"invalid_client"}`)
}))
defer ts.Close()
c := newTestAzureClient(t, ts)
_, err := c.getAccessToken(context.Background())
if err == nil || !strings.Contains(err.Error(), "status 401") {
t.Fatalf("expected 401 error, got: %v", err)
}
}
func TestAzureGetAccessToken_MalformedJSON(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{not json`)
}))
defer ts.Close()
c := newTestAzureClient(t, ts)
_, err := c.getAccessToken(context.Background())
if err == nil || !strings.Contains(err.Error(), "parse token") {
t.Fatalf("expected parse error, got: %v", err)
}
}
func TestAzureGetAccessToken_EmptyToken(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"access_token":"","expires_in":3600}`)
}))
defer ts.Close()
c := newTestAzureClient(t, ts)
_, err := c.getAccessToken(context.Background())
if err == nil || !strings.Contains(err.Error(), "empty access token") {
t.Fatalf("expected empty-token error, got: %v", err)
}
}
func TestAzureGetAccessToken_NetworkError(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
c := newTestAzureClient(t, ts)
ts.Close()
_, err := c.getAccessToken(context.Background())
if err == nil {
t.Fatal("expected network error")
}
}
// ---------------------------------------------------------------------------
// ListCertificates
// ---------------------------------------------------------------------------
func TestAzureListCertificates_HappyPath(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.Contains(r.URL.Path, "/oauth2/v2.0/token"):
_, _ = io.WriteString(w, `{"access_token":"tok","expires_in":3600}`)
case strings.HasSuffix(r.URL.Path, "/certificates"):
_, _ = io.WriteString(w, `{"value":[{"id":"https://myvault.vault.azure.net/certificates/cert1/v1","attributes":{"exp":1735689600}}]}`)
default:
http.Error(w, "wrong path", http.StatusNotFound)
}
}))
defer ts.Close()
c := newTestAzureClient(t, ts)
certs, err := c.ListCertificates(context.Background(), c.config.VaultURL)
if err != nil {
t.Fatalf("ListCertificates: %v", err)
}
if len(certs) != 1 {
t.Errorf("certs count = %d; want 1", len(certs))
}
if certs[0].ID != "https://myvault.vault.azure.net/certificates/cert1/v1" {
t.Errorf("cert ID = %q", certs[0].ID)
}
}
func TestAzureListCertificates_TokenFailure(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/oauth2/v2.0/token") {
w.WriteHeader(http.StatusUnauthorized)
return
}
http.Error(w, "unreached", http.StatusInternalServerError)
}))
defer ts.Close()
c := newTestAzureClient(t, ts)
_, err := c.ListCertificates(context.Background(), c.config.VaultURL)
if err == nil || !strings.Contains(err.Error(), "access token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestAzureListCertificates_5xx(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/oauth2/v2.0/token") {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"access_token":"tok","expires_in":3600}`)
return
}
w.WriteHeader(http.StatusInternalServerError)
_, _ = io.WriteString(w, `vault upstream broken`)
}))
defer ts.Close()
c := newTestAzureClient(t, ts)
_, err := c.ListCertificates(context.Background(), c.config.VaultURL)
if err == nil || !strings.Contains(err.Error(), "status 500") {
t.Fatalf("expected 500 error, got: %v", err)
}
}
func TestAzureListCertificates_MalformedJSON(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/oauth2/v2.0/token") {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"access_token":"tok","expires_in":3600}`)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{not json`)
}))
defer ts.Close()
c := newTestAzureClient(t, ts)
_, err := c.ListCertificates(context.Background(), c.config.VaultURL)
if err == nil || !strings.Contains(err.Error(), "parse list") {
t.Fatalf("expected parse error, got: %v", err)
}
}
func TestAzureListCertificates_Pagination(t *testing.T) {
pageNum := atomic.Int32{}
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if strings.Contains(r.URL.Path, "/oauth2/v2.0/token") {
_, _ = io.WriteString(w, `{"access_token":"tok","expires_in":3600}`)
return
}
if strings.HasSuffix(r.URL.Path, "/certificates") {
n := pageNum.Add(1)
if n == 1 {
// First page returns one cert + nextLink
_, _ = io.WriteString(w, `{"value":[{"id":"https://myvault.vault.azure.net/certificates/cert1/v1","attributes":{"exp":0}}],"nextLink":"http://`+r.Host+`/certificates?page=2"}`)
return
}
// Second page (no nextLink) returns the second cert
_, _ = io.WriteString(w, `{"value":[{"id":"https://myvault.vault.azure.net/certificates/cert2/v1","attributes":{"exp":0}}]}`)
}
}))
defer ts.Close()
c := newTestAzureClient(t, ts)
certs, err := c.ListCertificates(context.Background(), c.config.VaultURL)
if err != nil {
t.Fatalf("ListCertificates: %v", err)
}
if len(certs) != 2 {
t.Errorf("expected 2 certs across 2 pages, got %d", len(certs))
}
}
// ---------------------------------------------------------------------------
// GetCertificate
// ---------------------------------------------------------------------------
func TestAzureGetCertificate_HappyPath(t *testing.T) {
cer := makeAzureCertCER(t)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if strings.Contains(r.URL.Path, "/oauth2/v2.0/token") {
_, _ = io.WriteString(w, `{"access_token":"tok","expires_in":3600}`)
return
}
// /certificates/{name}/{version}
w.Header().Set("Content-Type", "application/json")
body, _ := json.Marshal(map[string]any{
"id": "https://myvault.vault.azure.net/certificates/mycert/v1",
"cer": cer,
})
_, _ = w.Write(body)
}))
defer ts.Close()
c := newTestAzureClient(t, ts)
bundle, err := c.GetCertificate(context.Background(), c.config.VaultURL, "mycert", "v1")
if err != nil {
t.Fatalf("GetCertificate: %v", err)
}
if bundle == nil || bundle.CER != cer {
t.Errorf("bundle = %+v", bundle)
}
}
func TestAzureGetCertificate_404(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/oauth2/v2.0/token") {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"access_token":"tok","expires_in":3600}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer ts.Close()
c := newTestAzureClient(t, ts)
_, err := c.GetCertificate(context.Background(), c.config.VaultURL, "missing", "v1")
if err == nil || !strings.Contains(err.Error(), "status 404") {
t.Fatalf("expected 404 error, got: %v", err)
}
}
func TestAzureGetCertificate_MalformedJSON(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/oauth2/v2.0/token") {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"access_token":"tok","expires_in":3600}`)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{not json`)
}))
defer ts.Close()
c := newTestAzureClient(t, ts)
_, err := c.GetCertificate(context.Background(), c.config.VaultURL, "mycert", "v1")
if err == nil || !strings.Contains(err.Error(), "parse certificate") {
t.Fatalf("expected parse error, got: %v", err)
}
}
// ---------------------------------------------------------------------------
// New (constructor)
// ---------------------------------------------------------------------------
func TestNew_ConstructsHttpClient(t *testing.T) {
cfg := Config{
VaultURL: "https://myvault.vault.azure.net",
TenantID: "t",
ClientID: "c",
ClientSecret: "s",
}
src := New(cfg, quietAzureLogger())
if src == nil {
t.Fatal("New returned nil")
}
if src.client == nil {
t.Error("client not initialized")
}
}
@@ -0,0 +1,452 @@
package gcpsm
// Bundle M.Cloud (GCP-SM portion) — GCP Secret Manager discovery
// realclient failure-mode coverage. Closes finding H-004 (gcpsm portion).
//
// Strategy: write a fixture service-account JSON file at a t.TempDir()
// path with token_uri pointing at our httptest.Server. This means
// getAccessToken's hardcoded path (s.saKey.TokenURI) lands on the test
// server. For the secretmanager.googleapis.com URLs, use a custom
// http.RoundTripper that rewrites Host to the test server. Then exercise
// ListSecrets / AccessSecretVersion / getAccessToken end-to-end.
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/shankar0123/certctl/internal/config"
)
// rewritingTransport rewrites every request to the test server while
// preserving path + query.
type rewritingTransport struct {
target *httptest.Server
}
func (rt *rewritingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
newURL := *req.URL
newURL.Scheme = "http"
newURL.Host = rt.target.Listener.Addr().String()
newReq := req.Clone(req.Context())
newReq.URL = &newURL
newReq.Host = newURL.Host
return rt.target.Client().Transport.RoundTrip(newReq)
}
func quietGCPLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError}))
}
// generateTestRSAKey returns an RSA private key + its PEM encoding (PKCS#8).
func generateTestRSAKey(t *testing.T) (*rsa.PrivateKey, string) {
t.Helper()
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("gen rsa: %v", err)
}
der, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
t.Fatalf("marshal pkcs8: %v", err)
}
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
return priv, string(pemBytes)
}
// writeServiceAccountJSON writes a fake service-account credentials file
// at t.TempDir()/sa.json with token_uri pointing at the given test server.
// Returns the path.
func writeServiceAccountJSON(t *testing.T, ts *httptest.Server) string {
t.Helper()
_, pemKey := generateTestRSAKey(t)
tokenURI := ts.URL + "/token"
saJSON := `{
"type": "service_account",
"project_id": "test-project",
"private_key": ` + jsonString(pemKey) + `,
"client_email": "test@test-project.iam.gserviceaccount.com",
"token_uri": "` + tokenURI + `"
}`
dir := t.TempDir()
path := filepath.Join(dir, "sa.json")
if err := os.WriteFile(path, []byte(saJSON), 0o600); err != nil {
t.Fatalf("write sa.json: %v", err)
}
return path
}
// jsonString returns the JSON-quoted form of s (escapes \n, etc.).
func jsonString(s string) string {
// Simple escape: backslash + double quote + newlines.
out := strings.NewReplacer(
`\`, `\\`,
`"`, `\"`,
"\n", `\n`,
).Replace(s)
return `"` + out + `"`
}
// newTestGCPSource builds a Source pointing at the given test server,
// using a TempDir-backed service-account credentials file.
func newTestGCPSource(t *testing.T, ts *httptest.Server) *Source {
t.Helper()
saPath := writeServiceAccountJSON(t, ts)
httpClient := &http.Client{
Transport: &rewritingTransport{target: ts},
Timeout: 30 * time.Second,
}
return &Source{
cfg: &config.GCPSecretMgrDiscoveryConfig{
Project: "test-project",
Credentials: saPath,
},
httpClient: httpClient,
logger: quietGCPLogger(),
}
}
// ---------------------------------------------------------------------------
// loadServiceAccountKey
// ---------------------------------------------------------------------------
func TestLoadServiceAccountKey_HappyPath(t *testing.T) {
dir := t.TempDir()
_, pemKey := generateTestRSAKey(t)
saJSON := `{
"type": "service_account",
"project_id": "x",
"private_key": ` + jsonString(pemKey) + `,
"client_email": "x@x.iam.gserviceaccount.com",
"token_uri": "https://oauth2.googleapis.com/token"
}`
path := filepath.Join(dir, "sa.json")
if err := os.WriteFile(path, []byte(saJSON), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
saKey, rsaKey, err := loadServiceAccountKey(path)
if err != nil {
t.Fatalf("loadServiceAccountKey: %v", err)
}
if saKey.ClientEmail != "x@x.iam.gserviceaccount.com" {
t.Errorf("ClientEmail = %q", saKey.ClientEmail)
}
if rsaKey == nil {
t.Error("rsaKey nil")
}
}
func TestLoadServiceAccountKey_FileNotFound(t *testing.T) {
_, _, err := loadServiceAccountKey("/nonexistent/sa.json")
if err == nil || !strings.Contains(err.Error(), "cannot read") {
t.Fatalf("expected file-not-found error, got: %v", err)
}
}
func TestLoadServiceAccountKey_MalformedJSON(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "sa.json")
_ = os.WriteFile(path, []byte(`{not json`), 0o600)
_, _, err := loadServiceAccountKey(path)
if err == nil || !strings.Contains(err.Error(), "parse credentials") {
t.Fatalf("expected parse error, got: %v", err)
}
}
func TestLoadServiceAccountKey_BadPEM(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "sa.json")
saJSON := `{
"type": "service_account",
"private_key": "not-a-pem-block",
"client_email": "x@x.iam.gserviceaccount.com",
"token_uri": "https://oauth2.googleapis.com/token"
}`
_ = os.WriteFile(path, []byte(saJSON), 0o600)
_, _, err := loadServiceAccountKey(path)
if err == nil || !strings.Contains(err.Error(), "decode private key") {
t.Fatalf("expected decode error, got: %v", err)
}
}
func TestLoadServiceAccountKey_EmptyPrivateKey(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "sa.json")
saJSON := `{
"type": "service_account",
"private_key": "",
"client_email": "x@x.iam.gserviceaccount.com",
"token_uri": "https://oauth2.googleapis.com/token"
}`
_ = os.WriteFile(path, []byte(saJSON), 0o600)
saKey, rsaKey, err := loadServiceAccountKey(path)
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if saKey == nil {
t.Error("saKey nil with empty private_key")
}
if rsaKey != nil {
t.Error("rsaKey should be nil with empty private_key")
}
}
// ---------------------------------------------------------------------------
// getAccessToken
// ---------------------------------------------------------------------------
func TestGCPGetAccessToken_HappyPath(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"access_token":"gcp-tok","expires_in":3600,"token_type":"Bearer"}`)
}))
defer ts.Close()
s := newTestGCPSource(t, ts)
tok, err := s.getAccessToken(context.Background())
if err != nil {
t.Fatalf("getAccessToken: %v", err)
}
if tok != "gcp-tok" {
t.Errorf("token = %q", tok)
}
}
func TestGCPGetAccessToken_CachedReuse(t *testing.T) {
count := atomic.Int32{}
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
count.Add(1)
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"access_token":"tok","expires_in":3600}`)
}))
defer ts.Close()
s := newTestGCPSource(t, ts)
if _, err := s.getAccessToken(context.Background()); err != nil {
t.Fatalf("first: %v", err)
}
if _, err := s.getAccessToken(context.Background()); err != nil {
t.Fatalf("second: %v", err)
}
if count.Load() != 1 {
t.Errorf("token endpoint hit %d times; want 1 (cache miss)", count.Load())
}
}
func TestGCPGetAccessToken_4xx(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = io.WriteString(w, `{"error":"invalid_grant"}`)
}))
defer ts.Close()
s := newTestGCPSource(t, ts)
_, err := s.getAccessToken(context.Background())
if err == nil || !strings.Contains(err.Error(), "status 401") {
t.Fatalf("expected 401 error, got: %v", err)
}
}
func TestGCPGetAccessToken_MalformedJSON(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{not json`)
}))
defer ts.Close()
s := newTestGCPSource(t, ts)
_, err := s.getAccessToken(context.Background())
if err == nil || !strings.Contains(err.Error(), "parse token") {
t.Fatalf("expected parse error, got: %v", err)
}
}
func TestGCPGetAccessToken_EmptyToken(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"access_token":"","expires_in":3600}`)
}))
defer ts.Close()
s := newTestGCPSource(t, ts)
_, err := s.getAccessToken(context.Background())
if err == nil || !strings.Contains(err.Error(), "empty access token") {
t.Fatalf("expected empty-token error, got: %v", err)
}
}
func TestGCPGetAccessToken_LoadCredentialsFails(t *testing.T) {
s := &Source{
cfg: &config.GCPSecretMgrDiscoveryConfig{
Project: "x",
Credentials: "/nonexistent/sa.json",
},
httpClient: &http.Client{Timeout: 30 * time.Second},
logger: quietGCPLogger(),
}
_, err := s.getAccessToken(context.Background())
if err == nil || !strings.Contains(err.Error(), "load credentials") {
t.Fatalf("expected load-credentials error, got: %v", err)
}
}
// ---------------------------------------------------------------------------
// ListSecrets / AccessSecretVersion
// ---------------------------------------------------------------------------
func TestGCPListSecrets_HappyPath(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.HasSuffix(r.URL.Path, "/token"):
_, _ = io.WriteString(w, `{"access_token":"tok","expires_in":3600}`)
case strings.HasSuffix(r.URL.Path, "/secrets"):
_, _ = io.WriteString(w, `{"secrets":[{"name":"projects/p/secrets/cert1","labels":{"type":"certificate"}}]}`)
default:
http.Error(w, "wrong path", http.StatusNotFound)
}
}))
defer ts.Close()
s := newTestGCPSource(t, ts)
cli := &httpSMClient{source: s, logger: quietGCPLogger()}
secrets, err := cli.ListSecrets(context.Background(), "p")
if err != nil {
t.Fatalf("ListSecrets: %v", err)
}
if len(secrets) != 1 {
t.Errorf("expected 1 secret, got %d", len(secrets))
}
}
func TestGCPListSecrets_TokenFailure(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/token") {
w.WriteHeader(http.StatusUnauthorized)
return
}
}))
defer ts.Close()
s := newTestGCPSource(t, ts)
cli := &httpSMClient{source: s, logger: quietGCPLogger()}
_, err := cli.ListSecrets(context.Background(), "p")
if err == nil || !strings.Contains(err.Error(), "access token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestGCPListSecrets_5xx(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if strings.HasSuffix(r.URL.Path, "/token") {
_, _ = io.WriteString(w, `{"access_token":"tok","expires_in":3600}`)
return
}
w.WriteHeader(http.StatusInternalServerError)
}))
defer ts.Close()
s := newTestGCPSource(t, ts)
cli := &httpSMClient{source: s, logger: quietGCPLogger()}
_, err := cli.ListSecrets(context.Background(), "p")
if err == nil || !strings.Contains(err.Error(), "status 500") {
t.Fatalf("expected 500 error, got: %v", err)
}
}
func TestGCPListSecrets_MalformedJSON(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if strings.HasSuffix(r.URL.Path, "/token") {
_, _ = io.WriteString(w, `{"access_token":"tok","expires_in":3600}`)
return
}
_, _ = io.WriteString(w, `{not json`)
}))
defer ts.Close()
s := newTestGCPSource(t, ts)
cli := &httpSMClient{source: s, logger: quietGCPLogger()}
_, err := cli.ListSecrets(context.Background(), "p")
if err == nil || !strings.Contains(err.Error(), "parse list") {
t.Fatalf("expected parse error, got: %v", err)
}
}
func TestGCPAccessSecretVersion_HappyPath(t *testing.T) {
want := "secret payload data"
encoded := base64.StdEncoding.EncodeToString([]byte(want))
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.HasSuffix(r.URL.Path, "/token"):
_, _ = io.WriteString(w, `{"access_token":"tok","expires_in":3600}`)
case strings.HasSuffix(r.URL.Path, ":access"):
_, _ = io.WriteString(w, `{"payload":{"data":"`+encoded+`"}}`)
}
}))
defer ts.Close()
s := newTestGCPSource(t, ts)
cli := &httpSMClient{source: s, logger: quietGCPLogger()}
data, err := cli.AccessSecretVersion(context.Background(), "p", "mycert")
if err != nil {
t.Fatalf("AccessSecretVersion: %v", err)
}
if string(data) != want {
t.Errorf("data = %q; want %q", data, want)
}
}
func TestGCPAccessSecretVersion_404(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/token") {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"access_token":"tok","expires_in":3600}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer ts.Close()
s := newTestGCPSource(t, ts)
cli := &httpSMClient{source: s, logger: quietGCPLogger()}
_, err := cli.AccessSecretVersion(context.Background(), "p", "missing")
if err == nil || !strings.Contains(err.Error(), "status 404") {
t.Fatalf("expected 404 error, got: %v", err)
}
}
func TestGCPAccessSecretVersion_BadBase64(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if strings.HasSuffix(r.URL.Path, "/token") {
_, _ = io.WriteString(w, `{"access_token":"tok","expires_in":3600}`)
return
}
_, _ = io.WriteString(w, `{"payload":{"data":"!!!not-base64!!!"}}`)
}))
defer ts.Close()
s := newTestGCPSource(t, ts)
cli := &httpSMClient{source: s, logger: quietGCPLogger()}
_, err := cli.AccessSecretVersion(context.Background(), "p", "mycert")
if err == nil || !strings.Contains(err.Error(), "base64-decode") {
t.Fatalf("expected base64 error, got: %v", err)
}
}
// ---------------------------------------------------------------------------
// Name / Type
// ---------------------------------------------------------------------------
func TestGCPNameAndType(t *testing.T) {
s := New(&config.GCPSecretMgrDiscoveryConfig{}, quietGCPLogger())
if s.Name() != "GCP Secret Manager" {
t.Errorf("Name() = %q", s.Name())
}
if s.Type() != "gcp-sm" {
t.Errorf("Type() = %q", s.Type())
}
}