Files
certctl/internal/connector/discovery/gcpsm/gcpsm_test.go
T
shankar0123 7cb453a336 chore(fmt): repo-wide gofmt -w sweep — close drift surfaced by ci-pipeline-cleanup Phase 4
Mechanical reformat. The new 'gofmt drift' CI step (added in
ci-pipeline-cleanup Phase 4, commit 0f205a8) surfaced 111 files
with accumulated gofmt drift across cmd/, internal/, and deploy/test/.

Each file's diff is gofmt-standard: whitespace adjustments, intra-
group import sorting (alphabetical by import path within blank-line-
separated groups), and struct-tag column alignment. No semantic
changes — verified via 'git diff --ignore-all-space' which shows only
the line-position deltas from import reordering.

The gate stays in place after this commit. Going forward it catches
gofmt drift at PR time.
2026-04-30 22:33:57 +00:00

526 lines
14 KiB
Go

package gcpsm
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/pem"
"fmt"
"log/slog"
"math/big"
"os"
"testing"
"time"
"github.com/shankar0123/certctl/internal/config"
"github.com/shankar0123/certctl/internal/domain"
)
// mockSMClient implements SMClient for testing.
type mockSMClient struct {
secrets map[string][]byte
accessErrors map[string]error
listSecretsError error
listSecretsHook func(ctx context.Context, project string) ([]SecretEntry, error)
}
func newMockSMClient() *mockSMClient {
return &mockSMClient{
secrets: make(map[string][]byte),
accessErrors: make(map[string]error),
}
}
func (m *mockSMClient) ListSecrets(ctx context.Context, project string) ([]SecretEntry, error) {
if m.listSecretsHook != nil {
return m.listSecretsHook(ctx, project)
}
if m.listSecretsError != nil {
return nil, m.listSecretsError
}
var entries []SecretEntry
for name := range m.secrets {
entries = append(entries, SecretEntry{
Name: fmt.Sprintf("projects/%s/secrets/%s", project, name),
Labels: map[string]string{"type": "certificate"},
})
}
return entries, nil
}
func (m *mockSMClient) AccessSecretVersion(ctx context.Context, project, secretName string) ([]byte, error) {
if err, ok := m.accessErrors[secretName]; ok {
return nil, err
}
if data, ok := m.secrets[secretName]; ok {
return data, nil
}
return nil, fmt.Errorf("secret not found: %s", secretName)
}
// generateTestCertificate generates a self-signed test certificate.
func generateTestCertificate(cn string, expire time.Duration) (*x509.Certificate, []byte, error) {
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, nil, err
}
// Create a certificate template
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
CommonName: cn,
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(expire),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{
x509.ExtKeyUsageServerAuth,
},
DNSNames: []string{"example.com", "*.example.com"},
EmailAddresses: []string{"test@example.com"},
}
// Self-sign the certificate
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
if err != nil {
return nil, nil, err
}
// Parse the DER-encoded cert
cert, err := x509.ParseCertificate(certDER)
if err != nil {
return nil, nil, err
}
// Return both the cert object and the PEM-encoded version
pemData := pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: certDER,
})
return cert, pemData, nil
}
// createTempServiceAccountKey creates a temporary service account key file for testing.
func createTempServiceAccountKey() (string, error) {
tmpfile, err := os.CreateTemp("", "gcpsm-test-*.json")
if err != nil {
return "", err
}
defer tmpfile.Close()
// Generate a minimal RSA key for the test
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return "", err
}
// Convert to PKCS#8 PEM format
privateKeyDER, err := x509.MarshalPKCS8PrivateKey(privateKey)
if err != nil {
return "", err
}
privateKeyPEM := pem.EncodeToMemory(&pem.Block{
Type: "PRIVATE KEY",
Bytes: privateKeyDER,
})
// Create a minimal service account key JSON
keyJSON := fmt.Sprintf(`{
"type": "service_account",
"project_id": "test-project",
"private_key": %q,
"client_email": "test@test-project.iam.gserviceaccount.com",
"token_uri": "https://oauth2.googleapis.com/token"
}`, string(privateKeyPEM))
_, err = tmpfile.WriteString(keyJSON)
if err != nil {
os.Remove(tmpfile.Name())
return "", err
}
return tmpfile.Name(), nil
}
func TestValidateConfig_Success(t *testing.T) {
tmpfile, err := createTempServiceAccountKey()
if err != nil {
t.Fatalf("failed to create temp key file: %v", err)
}
defer os.Remove(tmpfile)
cfg := &config.GCPSecretMgrDiscoveryConfig{
Project: "test-project",
Credentials: tmpfile,
}
source := New(cfg, slog.Default())
if err := source.ValidateConfig(); err != nil {
t.Errorf("ValidateConfig failed: %v", err)
}
}
func TestValidateConfig_MissingProject(t *testing.T) {
tmpfile, err := createTempServiceAccountKey()
if err != nil {
t.Fatalf("failed to create temp key file: %v", err)
}
defer os.Remove(tmpfile)
cfg := &config.GCPSecretMgrDiscoveryConfig{
Project: "",
Credentials: tmpfile,
}
source := New(cfg, slog.Default())
if err := source.ValidateConfig(); err == nil {
t.Error("expected ValidateConfig to fail with missing project")
}
}
func TestValidateConfig_MissingCredentials(t *testing.T) {
cfg := &config.GCPSecretMgrDiscoveryConfig{
Project: "test-project",
Credentials: "",
}
source := New(cfg, slog.Default())
if err := source.ValidateConfig(); err == nil {
t.Error("expected ValidateConfig to fail with missing credentials")
}
}
func TestValidateConfig_InvalidCredentialsFile(t *testing.T) {
cfg := &config.GCPSecretMgrDiscoveryConfig{
Project: "test-project",
Credentials: "/nonexistent/path/to/creds.json",
}
source := New(cfg, slog.Default())
if err := source.ValidateConfig(); err == nil {
t.Error("expected ValidateConfig to fail with invalid credentials file")
}
}
func TestDiscover_Success(t *testing.T) {
tmpfile, err := createTempServiceAccountKey()
if err != nil {
t.Fatalf("failed to create temp key file: %v", err)
}
defer os.Remove(tmpfile)
// Generate two test certificates: one valid, one that will cause a parse error
validCert, validPEM, err := generateTestCertificate("test.example.com", 24*time.Hour)
if err != nil {
t.Fatalf("failed to generate test certificate: %v", err)
}
// Create a mock client with both secrets
mockClient := newMockSMClient()
mockClient.secrets["valid-cert"] = validPEM
mockClient.secrets["invalid-data"] = []byte("not a certificate at all")
cfg := &config.GCPSecretMgrDiscoveryConfig{
Project: "test-project",
Credentials: tmpfile,
}
source := NewWithClient(cfg, mockClient, slog.Default())
report, err := source.Discover(context.Background())
if err != nil {
t.Fatalf("Discover failed: %v", err)
}
// Should have discovered 1 valid certificate
if len(report.Certificates) != 1 {
t.Errorf("expected 1 certificate, got %d", len(report.Certificates))
}
// Should have 1 error (invalid-data)
if len(report.Errors) != 1 {
t.Errorf("expected 1 error, got %d", len(report.Errors))
}
// Verify certificate metadata
entry := report.Certificates[0]
if entry.CommonName != "test.example.com" {
t.Errorf("expected CN 'test.example.com', got '%s'", entry.CommonName)
}
if entry.KeyAlgorithm != "RSA" {
t.Errorf("expected RSA key algorithm, got %s", entry.KeyAlgorithm)
}
if entry.KeySize != 2048 {
t.Errorf("expected 2048-bit key, got %d", entry.KeySize)
}
// Verify source path
if !contains(report.Directories, "gcp-sm://test-project/") {
t.Errorf("expected directory 'gcp-sm://test-project/', got %v", report.Directories)
}
// Verify fingerprint calculation
if entry.FingerprintSHA256 == "" {
t.Error("expected non-empty fingerprint")
}
// Verify SANs
if !contains(entry.SANs, "example.com") || !contains(entry.SANs, "*.example.com") {
t.Errorf("expected DNS SANs, got %v", entry.SANs)
}
// Verify cert serial number matches
if entry.SerialNumber != fmt.Sprintf("%x", validCert.SerialNumber) {
t.Errorf("serial number mismatch: expected %x, got %s", validCert.SerialNumber, entry.SerialNumber)
}
}
func TestDiscover_EmptySecrets(t *testing.T) {
tmpfile, err := createTempServiceAccountKey()
if err != nil {
t.Fatalf("failed to create temp key file: %v", err)
}
defer os.Remove(tmpfile)
mockClient := newMockSMClient()
cfg := &config.GCPSecretMgrDiscoveryConfig{
Project: "test-project",
Credentials: tmpfile,
}
source := NewWithClient(cfg, mockClient, slog.Default())
report, err := source.Discover(context.Background())
if err != nil {
t.Fatalf("Discover failed: %v", err)
}
if len(report.Certificates) != 0 {
t.Errorf("expected 0 certificates, got %d", len(report.Certificates))
}
}
func TestDiscover_ListSecretsError(t *testing.T) {
tmpfile, err := createTempServiceAccountKey()
if err != nil {
t.Fatalf("failed to create temp key file: %v", err)
}
defer os.Remove(tmpfile)
// Create a mock client that fails on ListSecrets
mockClient := newMockSMClient()
mockClient.listSecretsError = fmt.Errorf("simulated ListSecrets error")
cfg := &config.GCPSecretMgrDiscoveryConfig{
Project: "test-project",
Credentials: tmpfile,
}
source := NewWithClient(cfg, mockClient, slog.Default())
report, err := source.Discover(context.Background())
// Should return error
if err == nil {
t.Error("expected Discover to fail when ListSecrets fails")
}
// But should still return a report with the error recorded
if report == nil || len(report.Errors) == 0 {
t.Error("expected error to be recorded in report")
}
}
func TestDiscover_AccessSecretError(t *testing.T) {
tmpfile, err := createTempServiceAccountKey()
if err != nil {
t.Fatalf("failed to create temp key file: %v", err)
}
defer os.Remove(tmpfile)
mockClient := newMockSMClient()
mockClient.accessErrors["broken-secret"] = fmt.Errorf("simulated AccessSecretVersion error")
// Add to list via the hook since we need it listed but access should fail
mockClient.listSecretsHook = func(ctx context.Context, project string) ([]SecretEntry, error) {
return []SecretEntry{
{Name: fmt.Sprintf("projects/%s/secrets/broken-secret", project), Labels: map[string]string{"type": "certificate"}},
}, nil
}
cfg := &config.GCPSecretMgrDiscoveryConfig{
Project: "test-project",
Credentials: tmpfile,
}
source := NewWithClient(cfg, mockClient, slog.Default())
report, _ := source.Discover(context.Background())
// Should record error but not fail the whole operation
if len(report.Errors) == 0 {
t.Error("expected error to be recorded in report")
}
if len(report.Certificates) != 0 {
t.Errorf("expected 0 certificates, got %d", len(report.Certificates))
}
}
func TestDiscover_AgentIDAndSourcePath(t *testing.T) {
tmpfile, err := createTempServiceAccountKey()
if err != nil {
t.Fatalf("failed to create temp key file: %v", err)
}
defer os.Remove(tmpfile)
_, certPEM, err := generateTestCertificate("test.example.com", 24*time.Hour)
if err != nil {
t.Fatalf("failed to generate test certificate: %v", err)
}
mockClient := newMockSMClient()
mockClient.secrets["my-cert"] = certPEM
cfg := &config.GCPSecretMgrDiscoveryConfig{
Project: "my-gcp-project",
Credentials: tmpfile,
}
source := NewWithClient(cfg, mockClient, slog.Default())
report, err := source.Discover(context.Background())
if err != nil {
t.Fatalf("Discover failed: %v", err)
}
// Verify agent ID
if report.AgentID != "cloud-gcp-sm" {
t.Errorf("expected agent ID 'cloud-gcp-sm', got '%s'", report.AgentID)
}
// Verify source path format
if len(report.Certificates) > 0 {
entry := report.Certificates[0]
expectedPath := "gcp-sm://my-gcp-project/my-cert"
if entry.SourcePath != expectedPath {
t.Errorf("expected source path '%s', got '%s'", expectedPath, entry.SourcePath)
}
}
}
func TestParseCertificate_PEM(t *testing.T) {
_, certPEM, err := generateTestCertificate("test.com", 24*time.Hour)
if err != nil {
t.Fatalf("failed to generate test certificate: %v", err)
}
cert, err := parseCertificate(certPEM)
if err != nil {
t.Errorf("failed to parse PEM certificate: %v", err)
}
if cert.Subject.CommonName != "test.com" {
t.Errorf("expected CN 'test.com', got '%s'", cert.Subject.CommonName)
}
}
func TestParseCertificate_Base64DER(t *testing.T) {
_, certPEM, err := generateTestCertificate("test.com", 24*time.Hour)
if err != nil {
t.Fatalf("failed to generate test certificate: %v", err)
}
// Decode PEM and re-encode as base64 DER
block, _ := pem.Decode(certPEM)
base64DER := []byte(base64.StdEncoding.EncodeToString(block.Bytes))
cert, err := parseCertificate(base64DER)
if err != nil {
t.Errorf("failed to parse base64 DER certificate: %v", err)
}
if cert.Subject.CommonName != "test.com" {
t.Errorf("expected CN 'test.com', got '%s'", cert.Subject.CommonName)
}
}
func TestParseCertificate_RawDER(t *testing.T) {
_, certPEM, err := generateTestCertificate("test.com", 24*time.Hour)
if err != nil {
t.Fatalf("failed to generate test certificate: %v", err)
}
// Decode PEM to get raw DER
block, _ := pem.Decode(certPEM)
cert, err := parseCertificate(block.Bytes)
if err != nil {
t.Errorf("failed to parse raw DER certificate: %v", err)
}
if cert.Subject.CommonName != "test.com" {
t.Errorf("expected CN 'test.com', got '%s'", cert.Subject.CommonName)
}
}
func TestParseCertificate_Invalid(t *testing.T) {
invalidData := []byte("not a certificate at all")
_, err := parseCertificate(invalidData)
if err == nil {
t.Error("expected parseCertificate to fail on invalid data")
}
}
// Helper function to check if a slice contains a string
func contains(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}
// TestSourceImplementsInterface ensures Source implements domain.DiscoverySource
func TestSourceImplementsInterface(t *testing.T) {
var _ domain.DiscoverySource = (*Source)(nil)
}
// BenchmarkDiscover provides basic performance metrics for discovery
func BenchmarkDiscover(b *testing.B) {
tmpfile, err := createTempServiceAccountKey()
if err != nil {
b.Fatalf("failed to create temp key file: %v", err)
}
defer os.Remove(tmpfile)
// Generate 10 test certificates
mockClient := newMockSMClient()
for i := 0; i < 10; i++ {
_, certPEM, err := generateTestCertificate(fmt.Sprintf("test%d.example.com", i), 24*time.Hour)
if err != nil {
b.Fatalf("failed to generate test certificate: %v", err)
}
mockClient.secrets[fmt.Sprintf("cert-%d", i)] = certPEM
}
cfg := &config.GCPSecretMgrDiscoveryConfig{
Project: "test-project",
Credentials: tmpfile,
}
source := NewWithClient(cfg, mockClient, slog.Default())
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := source.Discover(context.Background())
if err != nil {
b.Fatalf("Discover failed: %v", err)
}
}
}