mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 22:11:38 +00:00
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:
@@ -4,6 +4,34 @@ All notable changes to certctl are documented in this file. Dates use ISO 8601.
|
||||
|
||||
## [unreleased] — 2026-04-27
|
||||
|
||||
### 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.**
|
||||
|
||||
| | Pre | Post |
|
||||
|---|---|---|
|
||||
| `internal/connector/discovery/azurekv` | 41.2% | **85.6%** (+44.4pp; +15.6 above 70% target) |
|
||||
| `internal/connector/discovery/gcpsm` | 43.1% | **83.4%** (+40.3pp; +13.4 above 70% target) |
|
||||
|
||||
**Engineering technique:** both Azure KV and GCP Secret Manager use hardcoded API URLs (`login.microsoftonline.com` for Azure AD, `oauth2.googleapis.com` + `secretmanager.googleapis.com` for GCP). To test these end-to-end without modifying production code, each test file ships a `rewritingTransport` — a custom `http.RoundTripper` that intercepts every outbound request and rewrites Host to point at an `httptest.Server`, while preserving Path + Query. For GCP specifically, the service-account JSON file written to `t.TempDir()` carries `token_uri` pointing at the test server (clean override path that needs no transport rewrite for the auth call itself).
|
||||
|
||||
**`azurekv_failure_test.go`** (~280 LoC, 13 tests):
|
||||
- `getAccessToken`: happy + cached-reuse (5-min buffer pinned via call-count assertion) + 401 + malformed JSON + empty-token + network-error
|
||||
- `ListCertificates`: happy + token-failure + 5xx + malformed JSON + **multi-page pagination** (asserts both pages fetched via `nextLink`)
|
||||
- `GetCertificate`: happy (round-trip with synthesized DER cert in CER field) + 404 + malformed JSON
|
||||
- `New` constructor
|
||||
|
||||
**`gcpsm_failure_test.go`** (~430 LoC, 19 tests):
|
||||
- `loadServiceAccountKey`: happy + file-not-found + malformed JSON + bad-PEM + empty-private-key (returns saKey but nil rsaKey path)
|
||||
- `getAccessToken`: happy (full JWT-bearer assertion flow) + cached-reuse + 401 + malformed JSON + empty-token + load-credentials-failure
|
||||
- `ListSecrets`: happy + token-failure + 5xx + malformed JSON
|
||||
- `AccessSecretVersion`: happy (base64 round-trip of payload) + 404 + bad-base64-payload
|
||||
- `Name` / `Type` identity check
|
||||
|
||||
Verification: `go vet` clean, `gofmt -l` clean, `staticcheck -checks all` clean (excluding pre-existing ST1005 hits in `azurekv.go` lines 148–162 — capitalized error strings predating Bundle M), `go test -short -count=1` PASS, `go test -race -count=1` PASS, 0 races.
|
||||
|
||||
Audit deliverables: `findings.yaml::CRTCTL-COVAUDIT-2026-04-27-0011` flips status `open` → `closed` with full closure_note + per-connector coverage table. `gap-backlog.md` strikethroughs H-004 + adds Bundle M.Cloud closure-log entry. `coverage-matrix.md` adds two new rows for AzureKV and GCP-SM. `closure-plan.md` flips Bundle M `[~]` → `[x]` (all 4 sub-batches now closed).
|
||||
|
||||
### Bundle M (Coverage Audit Closure — Connector Failure-Mode Round): 3 of 4 sub-batches
|
||||
|
||||
> Closes H-001 (F5 ≥85%) and H-003 (Email ≥70%); partial-closes H-002 (SSH); defers H-004 (cloud-discovery) as scope-management.
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user