Files
certctl/internal/connector/target/azurekv/azurekv_test.go
T
shankar0123 14fcc82cda target(azurekv): SDK-driven Azure Key Vault target connector
Closes Rank 5 (Azure half) of the 2026-05-03 Infisical deep-research
deliverable (cowork/infisical-deep-research-results.md Part 5).
Pre-fix, certctl had no path to deploy certs to Azure-managed TLS-
termination endpoints (Application Gateway / Front Door / App Service
/ Container Apps) — operators terminating TLS at Azure had to use
manual `az keyvault certificate import` invocations or external
automation. This commit lands the SDK-driven Azure Key Vault target
connector that closes the gap, mirroring the AWS ACM target shape
shipped in commit 54033aa.

Architecture:
  - internal/connector/target/azurekv/azurekv.go — Connector wraps
    *azcertificates.Client behind the KeyVaultClient interface seam
    (mirrors awsacm's ACMClient + awsacmpca's ACMPCAClient). Lives
    in azurekv.go alongside the PFX (PKCS#12) wrapping helper that
    bundles the operator-supplied PEM cert + chain + key into the
    base64-PFX wire format azcertificates.ImportCertificate accepts.
  - internal/connector/target/azurekv/sdk_client.go — SDK-loading
    code isolated so the test path (NewWithClient) compiles without
    pulling azcore + azidentity transitive deps into the test
    binary. DefaultAzureCredential / ManagedIdentityCredential /
    EnvironmentCredential / WorkloadIdentityCredential selected via
    Config.CredentialMode (closed enum).
  - Pre-deploy snapshot via GetCertificate(name, "" /* latest */) so
    on-import-failure rollback restores the previous cert. Mirrors
    Bundle 5+. The Azure-specific quirk: rollback creates a NEW
    VERSION (Key Vault doesn't support version-restore without
    soft-delete recovery, which we keep off the minimum-RBAC
    surface). Operators reading audit dashboards see e.g. v1=initial,
    v2=failed-renewal, v3=rollback-of-v2; the certctl-managed-by +
    certctl-certificate-id provenance tags + future certctl-rollback-of
    metadata tag let an operator filter rollback artifacts.
  - Provenance tags identical to AWS ACM
    (certctl-managed-by=certctl + certctl-certificate-id=<mc-id>),
    automatically applied on every import. Key Vault carries tags
    forward across versions (unlike ACM which strips on re-import),
    so no separate AddTags call is required.
  - DeploymentRequest.KeyPEM held in agent memory only; PFX wrapping
    happens in-memory via software.sslmate.com/src/go-pkcs12. No
    disk write.

Tests:
  - azurekv_test.go: 13-subtest happy-path + validation matrix —
    ValidateConfig (success / missing-vault-url / malformed-vault-
    url / missing-cert-name / invalid-credential-mode / reserved-
    tag rejection), DeployCertificate (fresh import / rollback-on-
    serial-mismatch / empty-key-rejected / no-client-rejected /
    SDK-error-surfaced), ValidateOnly (returns sentinel),
    ValidateDeployment (serial match / mismatch).
  - All tests use the NewWithClient injection seam; no real-Azure
    API calls.
  - go test -short -count=1 ./internal/connector/target/azurekv/...
    green.

Wiring:
  - internal/domain/connector.go: TargetTypeAzureKeyVault =
    "AzureKeyVault".
  - internal/service/target.go: validTargetTypes set extended.
  - cmd/agent/main.go::createTargetConnector: AzureKeyVault case
    arm mirroring the AWSACM shape exactly.
  - cmd/agent/agent_test.go::TestCreateTargetConnector_AllSupported
    Types: AzureKeyVault added to the type matrix + the InvalidJSON
    matrix (16 supported target types now, up from 15).

go.mod / go.sum:
  - github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 (direct).
  - github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 (direct).
  - github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/
    azcertificates v1.4.0 (direct). The deprecated
    /keyvault/azcertificates path appears as a transitive indirect
    via Microsoft's microsoft-authentication-library-for-go; we use
    the new /security/keyvault/ path exclusively.

Documentation:
  - docs/connectors.md "Azure Key Vault" section: config table, RBAC
    role recipe (off-the-shelf "Key Vault Certificates Officer" or
    custom role with 3 data-plane actions), AKS workload-identity /
    managed-identity / service-principal / default credential
    recipes, atomic-rollback contract + Azure-version semantics
    explanation, soft-delete caveat, App Gateway / Front Door
    Terraform attachment snippet, threat model carve-outs (no disk
    writes, mandatory provenance tags, no long-lived secrets in
    Config), 5-bullet procurement checklist crib.

Out of scope (intentional, flagged in V3-Pro forward path):
  - Azure Front Door direct-attach (UpdateRoutingConfig — different
    Azure RBAC scope).
  - App Gateway / App Service auto-bind (V3-Pro auto-attach).
  - Soft-delete recovery (acm:RecoverDeletedCertificate-equivalent
    requires extra RBAC; V2 keeps minimum-permission surface).
  - GCP Certificate Manager (separate cloud, separate connector).

Verified locally:
- gofmt clean.
- go vet ./internal/connector/target/azurekv/...
  ./internal/domain/... ./internal/service/...
  ./cmd/agent/...  clean.
- go test -short -count=1 ./internal/connector/target/azurekv/...
  ./cmd/agent/...  green (all 16 supported target types
  instantiate via the agent factory).

Reference: cowork/infisical-deep-research-results.md Part 5 Rank 5.
Acquisition prompt:
cowork/rank-5-aws-acm-azure-kv-target-adapters-prompt.md.
Companion commit (AWS half): 54033aa.
2026-05-03 22:43:45 +00:00

418 lines
14 KiB
Go

package azurekv_test
// Rank 5 of the 2026-05-03 Infisical deep-research deliverable
// (cowork/infisical-deep-research-results.md Part 5). Happy-path tests
// for the Azure Key Vault target connector. Mirrors the awsacm_test.go
// shape so cross-cloud regressions are bisectable side-by-side.
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"log/slog"
"math/big"
"strings"
"sync"
"testing"
"time"
"github.com/shankar0123/certctl/internal/connector/target"
"github.com/shankar0123/certctl/internal/connector/target/azurekv"
)
// mockKeyVaultClient fakes the KeyVaultClient interface seam.
type mockKeyVaultClient struct {
mu sync.Mutex
importCalls []*azurekv.ImportCertificateInput
getCalls []*azurekv.GetCertificateInput
listVersionsCalls []*azurekv.ListVersionsInput
importOutput *azurekv.ImportCertificateOutput
importErr error
getOutput *azurekv.GetCertificateOutput
getErr error
listOutput *azurekv.ListVersionsOutput
listErr error
rollbackImportErr error // injected on the SECOND import call
}
func (m *mockKeyVaultClient) ImportCertificate(ctx context.Context, in *azurekv.ImportCertificateInput) (*azurekv.ImportCertificateOutput, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.importCalls = append(m.importCalls, in)
if len(m.importCalls) > 1 && m.rollbackImportErr != nil {
return nil, m.rollbackImportErr
}
if m.importErr != nil {
return nil, m.importErr
}
if m.importOutput != nil {
return m.importOutput, nil
}
return &azurekv.ImportCertificateOutput{
VersionID: "01234567890abcdef01234567890abcd",
KID: "https://test-vault.vault.azure.net/certificates/" + in.CertificateName + "/01234567890abcdef01234567890abcd",
}, nil
}
func (m *mockKeyVaultClient) GetCertificate(ctx context.Context, in *azurekv.GetCertificateInput) (*azurekv.GetCertificateOutput, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.getCalls = append(m.getCalls, in)
if m.getErr != nil {
return nil, m.getErr
}
if m.getOutput != nil {
return m.getOutput, nil
}
return &azurekv.GetCertificateOutput{}, nil
}
func (m *mockKeyVaultClient) ListVersions(ctx context.Context, in *azurekv.ListVersionsInput) (*azurekv.ListVersionsOutput, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.listVersionsCalls = append(m.listVersionsCalls, in)
if m.listErr != nil {
return nil, m.listErr
}
if m.listOutput != nil {
return m.listOutput, nil
}
return &azurekv.ListVersionsOutput{}, nil
}
func quietTestLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError}))
}
// generateTestCert creates a self-signed ECDSA P-256 cert + key for tests.
// Mirrors the awsacm test helper but emits the cert+key as separate
// PEM strings (the connector handles the PFX wrapping internally).
// Returns (certPEM, keyPEM, derBytes, serial).
func generateTestCert(t *testing.T, cn string) (certPEM, keyPEM string, derBytes []byte, serial string) {
t.Helper()
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
serialNum, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
tmpl := &x509.Certificate{
SerialNumber: serialNum,
Subject: pkix.Name{CommonName: cn},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
BasicConstraintsValid: true,
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
if err != nil {
t.Fatalf("create cert: %v", err)
}
derBytes = der
certPEM = string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))
keyDER, _ := x509.MarshalECPrivateKey(priv)
keyPEM = string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}))
hex := fmt.Sprintf("%x", serialNum)
if len(hex)%2 == 1 {
hex = "0" + hex
}
var b strings.Builder
for i := 0; i < len(hex); i += 2 {
if i > 0 {
b.WriteByte(':')
}
b.WriteString(hex[i : i+2])
}
serial = b.String()
return
}
func TestAzureKV_ValidateConfig_Success(t *testing.T) {
ctx := context.Background()
c := azurekv.NewWithClient(nil, &mockKeyVaultClient{}, quietTestLogger())
cfg := azurekv.Config{
VaultURL: "https://test-vault.vault.azure.net",
CertificateName: "demo-cert",
}
raw, _ := json.Marshal(cfg)
if err := c.ValidateConfig(ctx, raw); err != nil {
t.Fatalf("ValidateConfig: %v", err)
}
}
func TestAzureKV_ValidateConfig_MissingVaultURL(t *testing.T) {
ctx := context.Background()
c := azurekv.NewWithClient(nil, &mockKeyVaultClient{}, quietTestLogger())
raw, _ := json.Marshal(azurekv.Config{CertificateName: "x"})
err := c.ValidateConfig(ctx, raw)
if err == nil || !strings.Contains(err.Error(), "vault_url is required") {
t.Errorf("expected vault_url-required error; got %v", err)
}
}
func TestAzureKV_ValidateConfig_MalformedVaultURL(t *testing.T) {
ctx := context.Background()
c := azurekv.NewWithClient(nil, &mockKeyVaultClient{}, quietTestLogger())
raw, _ := json.Marshal(azurekv.Config{
VaultURL: "http://not-https",
CertificateName: "demo",
})
err := c.ValidateConfig(ctx, raw)
if err == nil || !strings.Contains(err.Error(), "vault_url malformed") {
t.Errorf("expected vault_url-malformed error; got %v", err)
}
}
func TestAzureKV_ValidateConfig_MissingCertName(t *testing.T) {
ctx := context.Background()
c := azurekv.NewWithClient(nil, &mockKeyVaultClient{}, quietTestLogger())
raw, _ := json.Marshal(azurekv.Config{VaultURL: "https://x.vault.azure.net"})
err := c.ValidateConfig(ctx, raw)
if err == nil || !strings.Contains(err.Error(), "certificate_name is required") {
t.Errorf("expected cert-name-required error; got %v", err)
}
}
func TestAzureKV_ValidateConfig_InvalidCredentialMode(t *testing.T) {
ctx := context.Background()
c := azurekv.NewWithClient(nil, &mockKeyVaultClient{}, quietTestLogger())
raw, _ := json.Marshal(azurekv.Config{
VaultURL: "https://test-vault.vault.azure.net",
CertificateName: "demo",
CredentialMode: "invalid-mode",
})
err := c.ValidateConfig(ctx, raw)
if err == nil || !strings.Contains(err.Error(), "credential_mode invalid") {
t.Errorf("expected credential_mode-invalid error; got %v", err)
}
}
func TestAzureKV_ValidateConfig_RejectsReservedTags(t *testing.T) {
ctx := context.Background()
c := azurekv.NewWithClient(nil, &mockKeyVaultClient{}, quietTestLogger())
raw, _ := json.Marshal(azurekv.Config{
VaultURL: "https://test-vault.vault.azure.net",
CertificateName: "demo",
Tags: map[string]string{"certctl-managed-by": "spoofed"},
})
err := c.ValidateConfig(ctx, raw)
if err == nil || !strings.Contains(err.Error(), "reserved provenance key") {
t.Errorf("expected reserved-key rejection; got %v", err)
}
}
func TestAzureKV_DeployCertificate_FreshImport(t *testing.T) {
ctx := context.Background()
certPEM, keyPEM, derBytes, serial := generateTestCert(t, "fresh.example.com")
mock := &mockKeyVaultClient{
// Snapshot read returns NotFound-equivalent (empty CER).
// Post-verify returns the CER bytes of the cert we just imported.
getOutput: &azurekv.GetCertificateOutput{
VersionID: "01234567890abcdef01234567890abcd",
Serial: serial,
CERBytes: derBytes,
},
}
cfg := &azurekv.Config{
VaultURL: "https://test-vault.vault.azure.net",
CertificateName: "fresh-cert",
}
c := azurekv.NewWithClient(cfg, mock, quietTestLogger())
res, err := c.DeployCertificate(ctx, target.DeploymentRequest{
CertPEM: certPEM,
KeyPEM: keyPEM,
Metadata: map[string]string{"certificate_id": "mc-fresh"},
})
if err != nil {
t.Fatalf("DeployCertificate: %v", err)
}
if !res.Success {
t.Errorf("expected Success=true")
}
if res.DeploymentID == "" {
t.Errorf("expected version ID populated")
}
if !strings.HasPrefix(res.TargetAddress, "https://test-vault.vault.azure.net/certificates/fresh-cert/") {
t.Errorf("expected KID URI in TargetAddress; got %s", res.TargetAddress)
}
if len(mock.importCalls) != 1 {
t.Errorf("expected exactly 1 ImportCertificate call; got %d", len(mock.importCalls))
}
// PFX is the wire format; assert the import call carries non-empty PFX bytes.
if mock.importCalls[0].PFXBase64 == "" {
t.Error("expected PFXBase64 populated on import call")
}
// Provenance tags applied.
if mock.importCalls[0].Tags["certctl-managed-by"] != "certctl" {
t.Error("expected certctl-managed-by=certctl provenance tag")
}
if mock.importCalls[0].Tags["certctl-certificate-id"] != "mc-fresh" {
t.Error("expected certctl-certificate-id provenance tag")
}
}
func TestAzureKV_DeployCertificate_RollbackOnSerialMismatch(t *testing.T) {
ctx := context.Background()
certPEM, keyPEM, _, _ := generateTestCert(t, "mismatch.example.com")
_, _, snapshotDER, _ := generateTestCert(t, "snapshot.example.com")
// Snapshot returns previous version's bytes; post-verify returns
// a serial that doesn't match the cert we just imported → trigger
// rollback.
mock := &mockKeyVaultClient{
getOutput: &azurekv.GetCertificateOutput{
Serial: "ff:ff:ff:ff",
CERBytes: snapshotDER,
},
}
cfg := &azurekv.Config{
VaultURL: "https://test-vault.vault.azure.net",
CertificateName: "rollback-cert",
}
c := azurekv.NewWithClient(cfg, mock, quietTestLogger())
_, err := c.DeployCertificate(ctx, target.DeploymentRequest{
CertPEM: certPEM,
KeyPEM: keyPEM,
Metadata: map[string]string{"certificate_id": "mc-rollback"},
})
if err == nil {
t.Fatal("expected error on serial mismatch")
}
if !strings.Contains(err.Error(), "rolled back") {
t.Errorf("expected error to mention rollback; got %v", err)
}
// Two import calls: initial + rollback.
if len(mock.importCalls) != 2 {
t.Errorf("expected 2 import calls (initial + rollback); got %d", len(mock.importCalls))
}
}
func TestAzureKV_DeployCertificate_EmptyKey(t *testing.T) {
ctx := context.Background()
certPEM, _, _, _ := generateTestCert(t, "nokey.example.com")
cfg := &azurekv.Config{
VaultURL: "https://test-vault.vault.azure.net",
CertificateName: "demo",
}
c := azurekv.NewWithClient(cfg, &mockKeyVaultClient{}, quietTestLogger())
_, err := c.DeployCertificate(ctx, target.DeploymentRequest{
CertPEM: certPEM,
KeyPEM: "",
})
if err == nil || !strings.Contains(err.Error(), "key_pem is required") {
t.Errorf("expected key-required error; got %v", err)
}
}
func TestAzureKV_DeployCertificate_NoClient(t *testing.T) {
ctx := context.Background()
cfg := &azurekv.Config{
VaultURL: "https://test-vault.vault.azure.net",
CertificateName: "demo",
}
c := azurekv.NewWithClient(cfg, nil, quietTestLogger())
_, err := c.DeployCertificate(ctx, target.DeploymentRequest{CertPEM: "x", KeyPEM: "y"})
if err == nil || !strings.Contains(err.Error(), "client not initialized") {
t.Errorf("expected client-not-initialized; got %v", err)
}
}
func TestAzureKV_ValidateOnly_NotSupported(t *testing.T) {
ctx := context.Background()
c := azurekv.NewWithClient(&azurekv.Config{
VaultURL: "https://test-vault.vault.azure.net",
CertificateName: "demo",
}, &mockKeyVaultClient{}, quietTestLogger())
err := c.ValidateOnly(ctx, target.DeploymentRequest{})
if !errors.Is(err, target.ErrValidateOnlyNotSupported) {
t.Errorf("expected ErrValidateOnlyNotSupported; got %v", err)
}
}
func TestAzureKV_ValidateDeployment_SerialMatch(t *testing.T) {
ctx := context.Background()
mock := &mockKeyVaultClient{
getOutput: &azurekv.GetCertificateOutput{Serial: "ab:cd:01"},
}
cfg := &azurekv.Config{
VaultURL: "https://test-vault.vault.azure.net",
CertificateName: "demo",
}
c := azurekv.NewWithClient(cfg, mock, quietTestLogger())
res, err := c.ValidateDeployment(ctx, target.ValidationRequest{Serial: "ab:cd:01"})
if err != nil {
t.Fatalf("ValidateDeployment: %v", err)
}
if !res.Valid {
t.Errorf("expected Valid=true; got %+v", res)
}
}
func TestAzureKV_ValidateDeployment_SerialMismatch(t *testing.T) {
ctx := context.Background()
mock := &mockKeyVaultClient{
getOutput: &azurekv.GetCertificateOutput{Serial: "00:00"},
}
cfg := &azurekv.Config{
VaultURL: "https://test-vault.vault.azure.net",
CertificateName: "demo",
}
c := azurekv.NewWithClient(cfg, mock, quietTestLogger())
res, err := c.ValidateDeployment(ctx, target.ValidationRequest{Serial: "ab:cd:01"})
if err != nil {
t.Fatalf("ValidateDeployment: %v", err)
}
if res.Valid {
t.Errorf("expected Valid=false on serial mismatch; got %+v", res)
}
}
// TestAzureKV_DeployCertificate_AzureSDKError_Surfaced pins that an
// underlying SDK error from the SDK client (e.g. 403 Forbidden, 404
// NotFound, 429 Throttled) bubbles up through the connector's wrap
// layer cleanly. Mirrors the AWS ACM failure-test shape — the Azure
// SDK uses azcore.ResponseError as its typed-error shape but Key
// Vault wraps it as a generic error in many cases; we test the
// generic-error wrap chain rather than reach into azcore.
func TestAzureKV_DeployCertificate_AzureSDKError_Surfaced(t *testing.T) {
ctx := context.Background()
certPEM, keyPEM, _, _ := generateTestCert(t, "sdkerr.example.com")
mock := &mockKeyVaultClient{
importErr: errors.New("azcertificates: 403 Forbidden — caller does not have certificates/import permission"),
}
cfg := &azurekv.Config{
VaultURL: "https://test-vault.vault.azure.net",
CertificateName: "sdkerr",
}
c := azurekv.NewWithClient(cfg, mock, quietTestLogger())
_, err := c.DeployCertificate(ctx, target.DeploymentRequest{
CertPEM: certPEM, KeyPEM: keyPEM,
Metadata: map[string]string{"certificate_id": "mc-sdkerr"},
})
if err == nil {
t.Fatal("expected SDK error")
}
if !strings.Contains(err.Error(), "ImportCertificate failed") {
t.Errorf("expected adapter wrap framing; got %v", err)
}
if !strings.Contains(err.Error(), "Forbidden") {
t.Errorf("expected SDK error substring preserved; got %v", err)
}
}