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/certctl-io/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()) } }