mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-12 07:08:51 +00:00
d14411faf5
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
453 lines
15 KiB
Go
453 lines
15 KiB
Go
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())
|
|
}
|
|
}
|