mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-11 11:48:59 +00:00
737c329824
Closes Top-10 fix #4 of the 2026-05-02 deployment-target audit re-run (see cowork/deployment-target-audit-2026-05-02-rerun/ RESULTS.md). Pre-fix, both IIS and WinCertStore's realExecutor invoked PowerShell via exec.CommandContext(ctx, ...) and relied entirely on the caller's ctx to provide a deadline. If the caller forgot to attach one (context.Background() in a deeply-nested path; an operator running an ad-hoc deploy via a CLI that doesn't default-deadline its ctx), a hung WinRM session blocked the deploy worker thread indefinitely. S2 (failure isolation) bar from the audit: "does a hung WinRM take down the deploy worker pool?" — today's answer was "potentially yes" for these two connectors. Post-fix the answer is "no, capped at the configured ExecDeadline (default 60s)". This commit: 1. Adds Config.ExecDeadline (time.Duration, json: "exec_deadline") to both connectors, defaulted to 60 seconds. WinCertStore defaults via the existing applyDefaults helper; IIS defaults inline at New() and inside ValidateConfig (the IIS connector has no shared applyDefaults helper today; out-of-scope to refactor one in for this minor fix). Operators on slow Windows links can override via the JSON config field exec_deadline. 2. Wraps realExecutor.Execute with a fallback context.WithTimeout that fires ONLY when ctx has no deadline of its own. Caller- supplied deadlines always win — the wrapper is a safety net, not a hard cap. defer cancel() guards against goroutine leaks. 3. Tests: - TestIIS_RealExecutor_AttachesDefaultDeadlineWhenCallerHasNone (passes context.Background; asserts the call returns within 500ms with an error). On Linux/macOS runners powershell.exe is missing and exec.Cmd fails fast; on Windows the wrapper's ctx deadline cancels the running PowerShell process. Either path returns well under 500ms. - TestIIS_RealExecutor_RespectsCallerDeadlineWhenSet (10s fallback executor deadline, 50ms caller ctx; asserts caller deadline wins). - TestIIS_RealExecutor_NoDeadlineWiredWhenZero (deadline=0 means no fallback wrapper; caller's tight ctx still bounds). - TestIIS_New_DefaultsExecDeadlineTo60s + TestIIS_New_RespectsExplicitExecDeadline pin the constructor's defaulting behavior (uses winrm mode so the test doesn't need powershell.exe in PATH). - Same five tests in wincertstore_test.go. 4. docs/connectors.md IIS + WinCertStore sections document the new exec_deadline field with: what it is (per-PowerShell- subprocess cap), default (60 seconds), override semantics (caller ctx deadline wins). No change to behavior when the caller already attaches a deadline (the common case in production code paths). Tests using the mock executor (mockExecutor in iis_test.go / wincertstore_test.go) are unaffected — they bypass realExecutor entirely. S2 cross-cutting scorecard rating in cowork/deployment-target-audit-2026-05-02-rerun/findings.json flips from "gap" to "pass" for IIS and WinCertStore (in any future re-audit). Verified locally: - gofmt / go vet / staticcheck clean across both packages. - go test -race -count=1 ./internal/connector/target/iis/... ./internal/connector/target/wincertstore/... green. Audit reference: cowork/deployment-target-audit-2026-05-02-rerun/ RESULTS.md Top-10 fix #4.
1523 lines
45 KiB
Go
1523 lines
45 KiB
Go
package iis
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"log/slog"
|
|
"math/big"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/shankar0123/certctl/internal/connector/target"
|
|
"github.com/shankar0123/certctl/internal/connector/target/certutil"
|
|
pkcs12 "software.sslmate.com/src/go-pkcs12"
|
|
)
|
|
|
|
// mockExecutor records PowerShell commands and returns configurable responses.
|
|
type mockExecutor struct {
|
|
// commands records all scripts passed to Execute in order
|
|
commands []string
|
|
// responses maps script substrings to (output, error) pairs.
|
|
// First matching substring wins.
|
|
responses map[string]mockResponse
|
|
// defaultOutput is returned when no response matches
|
|
defaultOutput string
|
|
// defaultErr is returned when no response matches
|
|
defaultErr error
|
|
}
|
|
|
|
type mockResponse struct {
|
|
output string
|
|
err error
|
|
}
|
|
|
|
func newMockExecutor() *mockExecutor {
|
|
return &mockExecutor{
|
|
responses: make(map[string]mockResponse),
|
|
}
|
|
}
|
|
|
|
func (m *mockExecutor) Execute(ctx context.Context, script string) (string, error) {
|
|
m.commands = append(m.commands, script)
|
|
for substr, resp := range m.responses {
|
|
if strings.Contains(script, substr) {
|
|
return resp.output, resp.err
|
|
}
|
|
}
|
|
return m.defaultOutput, m.defaultErr
|
|
}
|
|
|
|
func testLogger() *slog.Logger {
|
|
return slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
|
}
|
|
|
|
// --- ValidateConfig tests ---
|
|
|
|
func TestIISConnector_ValidateConfig_Success(t *testing.T) {
|
|
executor := newMockExecutor()
|
|
executor.responses["Get-Website"] = mockResponse{output: "Default Web Site\n", err: nil}
|
|
executor.responses["Test-Path"] = mockResponse{output: "True\n", err: nil}
|
|
|
|
cfg := Config{
|
|
SiteName: "Default Web Site",
|
|
CertStore: "My",
|
|
Port: 443,
|
|
}
|
|
|
|
// We need powershell.exe in PATH for LookPath — skip on non-Windows
|
|
connector := NewWithExecutor(&cfg, testLogger(), executor)
|
|
rawConfig, _ := json.Marshal(cfg)
|
|
|
|
// On non-Windows, LookPath("powershell.exe") will fail.
|
|
// We test the validation logic up to that point by checking the error message.
|
|
err := connector.ValidateConfig(context.Background(), rawConfig)
|
|
if err != nil {
|
|
// Q-1 closure (cat-s3-58ce7e9840be): platform-gated skip — IIS
|
|
// connector dispatches via powershell.exe; the binary only exists
|
|
// on Windows hosts. This branch lets the test pass on Linux/macOS
|
|
// CI runners where powershell.exe isn't available; on Windows
|
|
// runners the assertion below runs normally. The iis_connector.go
|
|
// production code has the same platform check; this skip mirrors
|
|
// it at test-fixture level.
|
|
if strings.Contains(err.Error(), "powershell.exe not found") {
|
|
t.Skip("Skipping: powershell.exe not available (non-Windows)")
|
|
}
|
|
t.Fatalf("ValidateConfig failed: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestIISConnector_ValidateConfig_InvalidJSON(t *testing.T) {
|
|
connector := NewWithExecutor(&Config{}, testLogger(), newMockExecutor())
|
|
err := connector.ValidateConfig(context.Background(), json.RawMessage(`{invalid}`))
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid JSON")
|
|
}
|
|
if !strings.Contains(err.Error(), "invalid IIS config") {
|
|
t.Errorf("expected 'invalid IIS config' in error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestIISConnector_ValidateConfig_MissingSiteName(t *testing.T) {
|
|
connector := NewWithExecutor(&Config{}, testLogger(), newMockExecutor())
|
|
cfg := Config{CertStore: "My"}
|
|
rawConfig, _ := json.Marshal(cfg)
|
|
|
|
err := connector.ValidateConfig(context.Background(), rawConfig)
|
|
if err == nil {
|
|
t.Fatal("expected error for missing site_name")
|
|
}
|
|
if !strings.Contains(err.Error(), "site_name") {
|
|
t.Errorf("expected error about site_name, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestIISConnector_ValidateConfig_MissingCertStore(t *testing.T) {
|
|
connector := NewWithExecutor(&Config{}, testLogger(), newMockExecutor())
|
|
cfg := Config{SiteName: "Default Web Site"}
|
|
rawConfig, _ := json.Marshal(cfg)
|
|
|
|
err := connector.ValidateConfig(context.Background(), rawConfig)
|
|
if err == nil {
|
|
t.Fatal("expected error for missing cert_store")
|
|
}
|
|
if !strings.Contains(err.Error(), "cert_store") {
|
|
t.Errorf("expected error about cert_store, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestIISConnector_ValidateConfig_InvalidSiteName_Injection(t *testing.T) {
|
|
connector := NewWithExecutor(&Config{}, testLogger(), newMockExecutor())
|
|
cfg := Config{
|
|
SiteName: "Default'; Drop-Database",
|
|
CertStore: "My",
|
|
}
|
|
rawConfig, _ := json.Marshal(cfg)
|
|
|
|
err := connector.ValidateConfig(context.Background(), rawConfig)
|
|
if err == nil {
|
|
t.Fatal("expected error for injection characters in site_name")
|
|
}
|
|
if !strings.Contains(err.Error(), "invalid characters") {
|
|
t.Errorf("expected 'invalid characters' in error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestIISConnector_ValidateConfig_InvalidCertStore_Injection(t *testing.T) {
|
|
connector := NewWithExecutor(&Config{}, testLogger(), newMockExecutor())
|
|
cfg := Config{
|
|
SiteName: "Default Web Site",
|
|
CertStore: "My$(whoami)",
|
|
}
|
|
rawConfig, _ := json.Marshal(cfg)
|
|
|
|
err := connector.ValidateConfig(context.Background(), rawConfig)
|
|
if err == nil {
|
|
t.Fatal("expected error for injection characters in cert_store")
|
|
}
|
|
}
|
|
|
|
func TestIISConnector_ValidateConfig_InvalidPort(t *testing.T) {
|
|
connector := NewWithExecutor(&Config{}, testLogger(), newMockExecutor())
|
|
cfg := Config{
|
|
SiteName: "Default Web Site",
|
|
CertStore: "My",
|
|
Port: 99999,
|
|
}
|
|
rawConfig, _ := json.Marshal(cfg)
|
|
|
|
err := connector.ValidateConfig(context.Background(), rawConfig)
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid port")
|
|
}
|
|
if !strings.Contains(err.Error(), "port") {
|
|
t.Errorf("expected error about port, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestIISConnector_ValidateConfig_InvalidIPAddress(t *testing.T) {
|
|
connector := NewWithExecutor(&Config{}, testLogger(), newMockExecutor())
|
|
cfg := Config{
|
|
SiteName: "Default Web Site",
|
|
CertStore: "My",
|
|
IPAddress: "not_an_ip",
|
|
}
|
|
rawConfig, _ := json.Marshal(cfg)
|
|
|
|
err := connector.ValidateConfig(context.Background(), rawConfig)
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid IP address")
|
|
}
|
|
if !strings.Contains(err.Error(), "ip_address") {
|
|
t.Errorf("expected error about ip_address, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestIISConnector_ValidateConfig_DefaultValues(t *testing.T) {
|
|
// Test that defaults are applied (port 443, IP *)
|
|
executor := newMockExecutor()
|
|
executor.responses["Get-Website"] = mockResponse{output: "TestSite\n", err: nil}
|
|
executor.responses["Test-Path"] = mockResponse{output: "True\n", err: nil}
|
|
|
|
cfg := Config{
|
|
SiteName: "TestSite",
|
|
CertStore: "WebHosting",
|
|
// Port and IPAddress intentionally left empty
|
|
}
|
|
|
|
connector := NewWithExecutor(&cfg, testLogger(), executor)
|
|
rawConfig, _ := json.Marshal(cfg)
|
|
|
|
err := connector.ValidateConfig(context.Background(), rawConfig)
|
|
if err != nil {
|
|
// Q-1 closure (cat-s3-58ce7e9840be): same platform-gate as
|
|
// TestIIS_ValidateConfig_Empty above; mirrors the production
|
|
// LookPath("powershell.exe") guard in iis_connector.go.
|
|
if strings.Contains(err.Error(), "powershell.exe not found") {
|
|
t.Skip("Skipping: powershell.exe not available (non-Windows)")
|
|
}
|
|
t.Fatalf("ValidateConfig failed: %v", err)
|
|
}
|
|
|
|
// Verify defaults were applied
|
|
if connector.config.Port != 443 {
|
|
t.Errorf("expected default port 443, got %d", connector.config.Port)
|
|
}
|
|
if connector.config.IPAddress != "*" {
|
|
t.Errorf("expected default IP '*', got %s", connector.config.IPAddress)
|
|
}
|
|
}
|
|
|
|
// --- DeployCertificate tests ---
|
|
|
|
// generateTestCertAndKey creates a self-signed ECDSA P-256 cert+key for testing.
|
|
func generateTestCertAndKey() (certPEM, keyPEM, chainPEM string, err error) {
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
return "", "", "", err
|
|
}
|
|
|
|
template := &x509.Certificate{
|
|
SerialNumber: big.NewInt(1),
|
|
Subject: pkix.Name{
|
|
CommonName: "test.example.com",
|
|
},
|
|
NotBefore: time.Now(),
|
|
NotAfter: time.Now().Add(24 * time.Hour),
|
|
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
|
BasicConstraintsValid: true,
|
|
DNSNames: []string{"test.example.com"},
|
|
}
|
|
|
|
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
|
if err != nil {
|
|
return "", "", "", err
|
|
}
|
|
|
|
certPEMStr := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}))
|
|
|
|
keyDER, err := x509.MarshalECPrivateKey(key)
|
|
if err != nil {
|
|
return "", "", "", err
|
|
}
|
|
keyPEMStr := string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}))
|
|
|
|
// Use the self-signed cert as its own "chain" for testing
|
|
chainPEMStr := certPEMStr
|
|
|
|
return certPEMStr, keyPEMStr, chainPEMStr, nil
|
|
}
|
|
|
|
func TestIISConnector_DeployCertificate_Success(t *testing.T) {
|
|
certPEM, keyPEM, chainPEM, err := generateTestCertAndKey()
|
|
if err != nil {
|
|
t.Fatalf("failed to generate test cert: %v", err)
|
|
}
|
|
|
|
executor := newMockExecutor()
|
|
executor.defaultOutput = "OK"
|
|
|
|
cfg := &Config{
|
|
Hostname: "web01.example.com",
|
|
SiteName: "Default Web Site",
|
|
CertStore: "My",
|
|
Port: 443,
|
|
IPAddress: "*",
|
|
}
|
|
|
|
connector := NewWithExecutor(cfg, testLogger(), executor)
|
|
|
|
result, err := connector.DeployCertificate(context.Background(), target.DeploymentRequest{
|
|
CertPEM: certPEM,
|
|
KeyPEM: keyPEM,
|
|
ChainPEM: chainPEM,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("DeployCertificate failed: %v", err)
|
|
}
|
|
if !result.Success {
|
|
t.Fatalf("expected success, got: %s", result.Message)
|
|
}
|
|
|
|
// Verify thumbprint is in metadata
|
|
if result.Metadata["thumbprint"] == "" {
|
|
t.Error("expected thumbprint in metadata")
|
|
}
|
|
// SHA-1 thumbprint = 40 hex chars uppercase
|
|
if len(result.Metadata["thumbprint"]) != 40 {
|
|
t.Errorf("expected 40-char thumbprint, got %d", len(result.Metadata["thumbprint"]))
|
|
}
|
|
|
|
// Bundle 10: idempotency probe runs FIRST (returns IDEM_MISS by default),
|
|
// then Bundle 5 snapshot, then import, then binding.
|
|
// Four PowerShell commands total on the success path.
|
|
if len(executor.commands) != 4 {
|
|
t.Errorf("expected 4 PowerShell commands (probe, snapshot, import, binding), got %d", len(executor.commands))
|
|
}
|
|
|
|
// First command should be the Bundle 10 idempotency probe.
|
|
if len(executor.commands) > 0 && !strings.Contains(executor.commands[0], "# CERTCTL_IDEM_PROBE") {
|
|
t.Errorf("expected # CERTCTL_IDEM_PROBE in first command, got: %s", executor.commands[0])
|
|
}
|
|
|
|
// Second command should be the Bundle 5 snapshot.
|
|
if len(executor.commands) > 1 && !strings.Contains(executor.commands[1], "# CERTCTL_SNAPSHOT") {
|
|
t.Errorf("expected # CERTCTL_SNAPSHOT in second command, got: %s", executor.commands[1])
|
|
}
|
|
|
|
// Third command should be PFX import.
|
|
if len(executor.commands) > 2 && !strings.Contains(executor.commands[2], "Import-PfxCertificate") {
|
|
t.Errorf("expected Import-PfxCertificate in third command, got: %s", executor.commands[2])
|
|
}
|
|
|
|
// Fourth command should be binding update.
|
|
if len(executor.commands) > 3 && !strings.Contains(executor.commands[3], "New-WebBinding") {
|
|
t.Errorf("expected New-WebBinding in fourth command, got: %s", executor.commands[3])
|
|
}
|
|
|
|
// Verify metadata
|
|
if result.Metadata["site_name"] != "Default Web Site" {
|
|
t.Errorf("expected site_name in metadata")
|
|
}
|
|
if result.Metadata["cert_store"] != "My" {
|
|
t.Errorf("expected cert_store in metadata")
|
|
}
|
|
if _, ok := result.Metadata["duration_ms"]; !ok {
|
|
t.Error("expected duration_ms in metadata")
|
|
}
|
|
}
|
|
|
|
func TestIIS_Idempotent_SkipsDeployWhenBindingMatches(t *testing.T) {
|
|
certPEM, keyPEM, chainPEM, err := generateTestCertAndKey()
|
|
if err != nil {
|
|
t.Fatalf("failed to generate test cert: %v", err)
|
|
}
|
|
|
|
executor := newMockExecutor()
|
|
// Seed the probe to return IDEM_MATCH
|
|
executor.responses["# CERTCTL_IDEM_PROBE"] = mockResponse{output: "IDEM_MATCH\n", err: nil}
|
|
|
|
cfg := &Config{
|
|
Hostname: "web01.example.com",
|
|
SiteName: "Default Web Site",
|
|
CertStore: "My",
|
|
Port: 443,
|
|
IPAddress: "*",
|
|
}
|
|
|
|
connector := NewWithExecutor(cfg, testLogger(), executor)
|
|
|
|
result, err := connector.DeployCertificate(context.Background(), target.DeploymentRequest{
|
|
CertPEM: certPEM,
|
|
KeyPEM: keyPEM,
|
|
ChainPEM: chainPEM,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("DeployCertificate failed: %v", err)
|
|
}
|
|
if !result.Success {
|
|
t.Fatalf("expected success, got: %s", result.Message)
|
|
}
|
|
|
|
// Verify idempotent flag is set
|
|
if result.Metadata["idempotent"] != "true" {
|
|
t.Errorf("expected idempotent=true, got %s", result.Metadata["idempotent"])
|
|
}
|
|
|
|
// Only the probe should have run, no import/binding calls
|
|
if len(executor.commands) != 1 {
|
|
t.Errorf("expected 1 command (probe only), got %d", len(executor.commands))
|
|
}
|
|
if !strings.Contains(executor.commands[0], "# CERTCTL_IDEM_PROBE") {
|
|
t.Errorf("expected probe command, got: %s", executor.commands[0])
|
|
}
|
|
|
|
// Verify no Import-PfxCertificate call
|
|
for i, cmd := range executor.commands {
|
|
if strings.Contains(cmd, "Import-PfxCertificate") {
|
|
t.Errorf("command %d should not contain Import-PfxCertificate (idempotent short-circuit): %s", i, cmd)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIIS_Idempotent_DifferentBinding_FallsThroughToDeploy(t *testing.T) {
|
|
certPEM, keyPEM, chainPEM, err := generateTestCertAndKey()
|
|
if err != nil {
|
|
t.Fatalf("failed to generate test cert: %v", err)
|
|
}
|
|
|
|
executor := newMockExecutor()
|
|
executor.defaultOutput = "OK"
|
|
// Seed the probe to return IDEM_MISS
|
|
executor.responses["# CERTCTL_IDEM_PROBE"] = mockResponse{output: "IDEM_MISS\n", err: nil}
|
|
|
|
cfg := &Config{
|
|
Hostname: "web01.example.com",
|
|
SiteName: "Default Web Site",
|
|
CertStore: "My",
|
|
Port: 443,
|
|
IPAddress: "*",
|
|
}
|
|
|
|
connector := NewWithExecutor(cfg, testLogger(), executor)
|
|
|
|
result, err := connector.DeployCertificate(context.Background(), target.DeploymentRequest{
|
|
CertPEM: certPEM,
|
|
KeyPEM: keyPEM,
|
|
ChainPEM: chainPEM,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("DeployCertificate failed: %v", err)
|
|
}
|
|
if !result.Success {
|
|
t.Fatalf("expected success, got: %s", result.Message)
|
|
}
|
|
|
|
// Verify idempotent flag is NOT set
|
|
if result.Metadata["idempotent"] != "" {
|
|
t.Errorf("expected no idempotent flag, got %s", result.Metadata["idempotent"])
|
|
}
|
|
|
|
// Full flow: probe + snapshot + import + binding = 4 commands
|
|
if len(executor.commands) != 4 {
|
|
t.Errorf("expected 4 commands (probe, snapshot, import, binding), got %d", len(executor.commands))
|
|
}
|
|
|
|
// Verify probe ran first
|
|
if !strings.Contains(executor.commands[0], "# CERTCTL_IDEM_PROBE") {
|
|
t.Errorf("expected probe as first command, got: %s", executor.commands[0])
|
|
}
|
|
|
|
// Verify import happened
|
|
hasImport := false
|
|
for _, cmd := range executor.commands {
|
|
if strings.Contains(cmd, "Import-PfxCertificate") {
|
|
hasImport = true
|
|
break
|
|
}
|
|
}
|
|
if !hasImport {
|
|
t.Error("expected Import-PfxCertificate in commands")
|
|
}
|
|
}
|
|
|
|
func TestIISConnector_DeployCertificate_MissingKeyPEM(t *testing.T) {
|
|
certPEM, _, chainPEM, err := generateTestCertAndKey()
|
|
if err != nil {
|
|
t.Fatalf("failed to generate test cert: %v", err)
|
|
}
|
|
|
|
connector := NewWithExecutor(&Config{
|
|
SiteName: "Default Web Site",
|
|
CertStore: "My",
|
|
}, testLogger(), newMockExecutor())
|
|
|
|
result, err := connector.DeployCertificate(context.Background(), target.DeploymentRequest{
|
|
CertPEM: certPEM,
|
|
KeyPEM: "", // Missing key
|
|
ChainPEM: chainPEM,
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error for missing KeyPEM")
|
|
}
|
|
if result.Success {
|
|
t.Fatal("expected failure result")
|
|
}
|
|
if !strings.Contains(err.Error(), "private key") {
|
|
t.Errorf("expected error about private key, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestIISConnector_DeployCertificate_InvalidCertPEM(t *testing.T) {
|
|
_, keyPEM, _, err := generateTestCertAndKey()
|
|
if err != nil {
|
|
t.Fatalf("failed to generate test key: %v", err)
|
|
}
|
|
|
|
connector := NewWithExecutor(&Config{
|
|
SiteName: "Default Web Site",
|
|
CertStore: "My",
|
|
}, testLogger(), newMockExecutor())
|
|
|
|
result, err := connector.DeployCertificate(context.Background(), target.DeploymentRequest{
|
|
CertPEM: "not a valid cert",
|
|
KeyPEM: keyPEM,
|
|
ChainPEM: "",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid cert PEM")
|
|
}
|
|
if result.Success {
|
|
t.Fatal("expected failure result")
|
|
}
|
|
}
|
|
|
|
func TestIISConnector_DeployCertificate_InvalidKeyPEM(t *testing.T) {
|
|
certPEM, _, _, err := generateTestCertAndKey()
|
|
if err != nil {
|
|
t.Fatalf("failed to generate test cert: %v", err)
|
|
}
|
|
|
|
connector := NewWithExecutor(&Config{
|
|
SiteName: "Default Web Site",
|
|
CertStore: "My",
|
|
}, testLogger(), newMockExecutor())
|
|
|
|
result, err := connector.DeployCertificate(context.Background(), target.DeploymentRequest{
|
|
CertPEM: certPEM,
|
|
KeyPEM: "not a valid key",
|
|
ChainPEM: "",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid key PEM")
|
|
}
|
|
if result.Success {
|
|
t.Fatal("expected failure result")
|
|
}
|
|
}
|
|
|
|
func TestIISConnector_DeployCertificate_ImportFails(t *testing.T) {
|
|
certPEM, keyPEM, chainPEM, err := generateTestCertAndKey()
|
|
if err != nil {
|
|
t.Fatalf("failed to generate test cert: %v", err)
|
|
}
|
|
|
|
executor := newMockExecutor()
|
|
executor.responses["Import-PfxCertificate"] = mockResponse{
|
|
output: "Access denied",
|
|
err: fmt.Errorf("exit status 1"),
|
|
}
|
|
|
|
connector := NewWithExecutor(&Config{
|
|
SiteName: "Default Web Site",
|
|
CertStore: "My",
|
|
}, testLogger(), executor)
|
|
|
|
result, err := connector.DeployCertificate(context.Background(), target.DeploymentRequest{
|
|
CertPEM: certPEM,
|
|
KeyPEM: keyPEM,
|
|
ChainPEM: chainPEM,
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error when PFX import fails")
|
|
}
|
|
if result.Success {
|
|
t.Fatal("expected failure result")
|
|
}
|
|
if !strings.Contains(err.Error(), "PFX import failed") {
|
|
t.Errorf("expected 'PFX import failed' in error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// --- Bundle 5: pre-deploy binding snapshot + on-failure rollback ---
|
|
//
|
|
// Mock matchers below use the unique `# CERTCTL_*` PowerShell comment tags
|
|
// inserted by snapshotOldBinding / rollbackBinding / verifyRollback. The
|
|
// binding-update script is matched via "Remove-WebBinding" — that token is
|
|
// only present in the binding-update script (the rollback script uses
|
|
// "Remove-Item" instead, and the snapshot/verify scripts only read state).
|
|
// The import script is matched via "Import-PfxCertificate" (only present
|
|
// in the import script). This isolation is required because the rollback
|
|
// script's no-old-binding fallback branch contains "New-WebBinding", which
|
|
// would otherwise collide with the binding-update script and produce
|
|
// non-deterministic mock matching under Go's randomized map iteration.
|
|
|
|
func TestIIS_BindingUpdateFails_RemovesNewCert_RebindsOld(t *testing.T) {
|
|
certPEM, keyPEM, chainPEM, err := generateTestCertAndKey()
|
|
if err != nil {
|
|
t.Fatalf("failed to generate test cert: %v", err)
|
|
}
|
|
|
|
executor := newMockExecutor()
|
|
// Probe returns IDEM_MISS (cert not already deployed).
|
|
executor.responses["# CERTCTL_IDEM_PROBE"] = mockResponse{
|
|
output: "IDEM_MISS\n",
|
|
err: nil,
|
|
}
|
|
// Snapshot returns a pre-existing thumbprint (rollback target).
|
|
executor.responses["# CERTCTL_SNAPSHOT"] = mockResponse{
|
|
output: "OLD_THUMBPRINT:abc123\n",
|
|
err: nil,
|
|
}
|
|
// Import succeeds.
|
|
executor.responses["Import-PfxCertificate"] = mockResponse{output: "OK", err: nil}
|
|
// Binding update fails.
|
|
executor.responses["Remove-WebBinding"] = mockResponse{
|
|
output: "The website 'Default Web Site' already has a binding",
|
|
err: fmt.Errorf("exit status 1"),
|
|
}
|
|
// Rollback succeeds.
|
|
executor.responses["# CERTCTL_ROLLBACK"] = mockResponse{
|
|
output: "REBOUND_EXISTING\n",
|
|
err: nil,
|
|
}
|
|
// Verify confirms old thumbprint is back.
|
|
executor.responses["# CERTCTL_VERIFY"] = mockResponse{
|
|
output: "VERIFY_OK\n",
|
|
err: nil,
|
|
}
|
|
|
|
connector := NewWithExecutor(&Config{
|
|
Hostname: "web01",
|
|
SiteName: "Default Web Site",
|
|
CertStore: "My",
|
|
Port: 443,
|
|
}, testLogger(), executor)
|
|
|
|
result, err := connector.DeployCertificate(context.Background(), target.DeploymentRequest{
|
|
CertPEM: certPEM,
|
|
KeyPEM: keyPEM,
|
|
ChainPEM: chainPEM,
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error when binding update fails")
|
|
}
|
|
if result.Success {
|
|
t.Fatal("expected failure result")
|
|
}
|
|
if !strings.Contains(err.Error(), "binding update failed") {
|
|
t.Errorf("expected error to mention 'binding update failed', got: %v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "rolled back") {
|
|
t.Errorf("expected error to mention 'rolled back', got: %v", err)
|
|
}
|
|
|
|
// Find the rollback script in the recorded commands.
|
|
var rollbackCmd string
|
|
for _, cmd := range executor.commands {
|
|
if strings.Contains(cmd, "# CERTCTL_ROLLBACK") {
|
|
rollbackCmd = cmd
|
|
break
|
|
}
|
|
}
|
|
if rollbackCmd == "" {
|
|
t.Fatal("expected rollback script to be executed")
|
|
}
|
|
|
|
// Rollback must remove the freshly-imported cert.
|
|
thumbprint := result.Metadata["thumbprint"]
|
|
if thumbprint == "" {
|
|
t.Fatal("expected thumbprint in metadata")
|
|
}
|
|
if !strings.Contains(rollbackCmd, "Remove-Item") {
|
|
t.Errorf("expected rollback to contain Remove-Item, got: %s", rollbackCmd)
|
|
}
|
|
if !strings.Contains(rollbackCmd, thumbprint) {
|
|
t.Errorf("expected rollback to reference new thumbprint %q, got: %s", thumbprint, rollbackCmd)
|
|
}
|
|
// Rollback must re-bind the old thumbprint.
|
|
if !strings.Contains(rollbackCmd, "AddSslCertificate('abc123'") {
|
|
t.Errorf("expected rollback to AddSslCertificate('abc123', ...), got: %s", rollbackCmd)
|
|
}
|
|
|
|
if result.Metadata["old_thumbprint"] != "abc123" {
|
|
t.Errorf("expected old_thumbprint=abc123 in metadata, got: %s", result.Metadata["old_thumbprint"])
|
|
}
|
|
if result.Metadata["rolled_back"] != "true" {
|
|
t.Errorf("expected rolled_back=true in metadata, got: %s", result.Metadata["rolled_back"])
|
|
}
|
|
}
|
|
|
|
func TestIIS_BindingUpdateFails_NoOldBinding_RemovesNewCertOnly(t *testing.T) {
|
|
certPEM, keyPEM, chainPEM, err := generateTestCertAndKey()
|
|
if err != nil {
|
|
t.Fatalf("failed to generate test cert: %v", err)
|
|
}
|
|
|
|
executor := newMockExecutor()
|
|
// Probe returns IDEM_MISS (cert not already deployed).
|
|
executor.responses["# CERTCTL_IDEM_PROBE"] = mockResponse{
|
|
output: "IDEM_MISS\n",
|
|
err: nil,
|
|
}
|
|
// First-time deploy: snapshot finds no existing binding.
|
|
executor.responses["# CERTCTL_SNAPSHOT"] = mockResponse{
|
|
output: "NO_OLD_BINDING\n",
|
|
err: nil,
|
|
}
|
|
executor.responses["Import-PfxCertificate"] = mockResponse{output: "OK", err: nil}
|
|
executor.responses["Remove-WebBinding"] = mockResponse{
|
|
output: "binding update failed",
|
|
err: fmt.Errorf("exit status 1"),
|
|
}
|
|
// Rollback succeeds (cert removed, no rebind).
|
|
executor.responses["# CERTCTL_ROLLBACK"] = mockResponse{
|
|
output: "CERT_REMOVED_NO_REBIND\n",
|
|
err: nil,
|
|
}
|
|
|
|
connector := NewWithExecutor(&Config{
|
|
Hostname: "web01",
|
|
SiteName: "Default Web Site",
|
|
CertStore: "My",
|
|
Port: 443,
|
|
}, testLogger(), executor)
|
|
|
|
result, err := connector.DeployCertificate(context.Background(), target.DeploymentRequest{
|
|
CertPEM: certPEM,
|
|
KeyPEM: keyPEM,
|
|
ChainPEM: chainPEM,
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error when binding update fails")
|
|
}
|
|
if result.Success {
|
|
t.Fatal("expected failure result")
|
|
}
|
|
|
|
// Find the rollback script.
|
|
var rollbackCmd string
|
|
for _, cmd := range executor.commands {
|
|
if strings.Contains(cmd, "# CERTCTL_ROLLBACK") {
|
|
rollbackCmd = cmd
|
|
break
|
|
}
|
|
}
|
|
if rollbackCmd == "" {
|
|
t.Fatal("expected rollback script to be executed")
|
|
}
|
|
|
|
// Rollback must remove the freshly-imported cert.
|
|
if !strings.Contains(rollbackCmd, "Remove-Item") {
|
|
t.Errorf("expected rollback to contain Remove-Item, got: %s", rollbackCmd)
|
|
}
|
|
// First-time deploy: rollback must NOT call AddSslCertificate (nothing
|
|
// to re-bind to). The rollback emits the CERT_REMOVED_NO_REBIND marker
|
|
// instead.
|
|
if strings.Contains(rollbackCmd, "AddSslCertificate") {
|
|
t.Errorf("expected no AddSslCertificate call when oldThumbprint is empty, got: %s", rollbackCmd)
|
|
}
|
|
if !strings.Contains(rollbackCmd, "CERT_REMOVED_NO_REBIND") {
|
|
t.Errorf("expected CERT_REMOVED_NO_REBIND marker in rollback script, got: %s", rollbackCmd)
|
|
}
|
|
|
|
// No verify script should run when oldThumbprint is empty.
|
|
for _, cmd := range executor.commands {
|
|
if strings.Contains(cmd, "# CERTCTL_VERIFY") {
|
|
t.Errorf("did not expect verify script when oldThumbprint is empty, got: %s", cmd)
|
|
}
|
|
}
|
|
|
|
if result.Metadata["old_thumbprint"] != "" {
|
|
t.Errorf("expected empty old_thumbprint in metadata, got: %s", result.Metadata["old_thumbprint"])
|
|
}
|
|
if result.Metadata["rolled_back"] != "true" {
|
|
t.Errorf("expected rolled_back=true in metadata, got: %s", result.Metadata["rolled_back"])
|
|
}
|
|
}
|
|
|
|
func TestIIS_BindingUpdateFails_RollbackAlsoFails_OperatorActionable(t *testing.T) {
|
|
certPEM, keyPEM, chainPEM, err := generateTestCertAndKey()
|
|
if err != nil {
|
|
t.Fatalf("failed to generate test cert: %v", err)
|
|
}
|
|
|
|
executor := newMockExecutor()
|
|
// Probe returns IDEM_MISS (cert not already deployed).
|
|
executor.responses["# CERTCTL_IDEM_PROBE"] = mockResponse{
|
|
output: "IDEM_MISS\n",
|
|
err: nil,
|
|
}
|
|
executor.responses["# CERTCTL_SNAPSHOT"] = mockResponse{
|
|
output: "OLD_THUMBPRINT:abc123\n",
|
|
err: nil,
|
|
}
|
|
executor.responses["Import-PfxCertificate"] = mockResponse{output: "OK", err: nil}
|
|
executor.responses["Remove-WebBinding"] = mockResponse{
|
|
output: "binding error",
|
|
err: fmt.Errorf("binding-step exit status 1"),
|
|
}
|
|
// Rollback ALSO fails — operator-actionable case.
|
|
executor.responses["# CERTCTL_ROLLBACK"] = mockResponse{
|
|
output: "rollback step failed",
|
|
err: fmt.Errorf("rollback-step exit status 2"),
|
|
}
|
|
|
|
connector := NewWithExecutor(&Config{
|
|
Hostname: "web01",
|
|
SiteName: "Default Web Site",
|
|
CertStore: "My",
|
|
Port: 443,
|
|
}, testLogger(), executor)
|
|
|
|
result, err := connector.DeployCertificate(context.Background(), target.DeploymentRequest{
|
|
CertPEM: certPEM,
|
|
KeyPEM: keyPEM,
|
|
ChainPEM: chainPEM,
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error when both binding and rollback fail")
|
|
}
|
|
if result.Success {
|
|
t.Fatal("expected failure result")
|
|
}
|
|
|
|
// Wrapped error must reference BOTH the binding error and the rollback
|
|
// error so an operator can see what state the host is in.
|
|
if !strings.Contains(err.Error(), "binding update failed") {
|
|
t.Errorf("expected error to mention binding error, got: %v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "rollback also failed") {
|
|
t.Errorf("expected error to mention rollback error, got: %v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "manual operator inspection required") {
|
|
t.Errorf("expected error to flag manual operator inspection, got: %v", err)
|
|
}
|
|
|
|
// Metadata must explicitly flag manual action and surface both errors.
|
|
if result.Metadata["manual_action_required"] != "true" {
|
|
t.Errorf("expected manual_action_required=true in metadata, got: %s", result.Metadata["manual_action_required"])
|
|
}
|
|
if result.Metadata["rolled_back"] != "false" {
|
|
t.Errorf("expected rolled_back=false in metadata, got: %s", result.Metadata["rolled_back"])
|
|
}
|
|
if result.Metadata["rollback_error"] == "" {
|
|
t.Error("expected rollback_error to be populated in metadata")
|
|
}
|
|
if result.Metadata["binding_error"] == "" {
|
|
t.Error("expected binding_error to be populated in metadata")
|
|
}
|
|
if result.Metadata["thumbprint"] == "" {
|
|
t.Error("expected thumbprint in metadata even on rollback failure")
|
|
}
|
|
if result.Metadata["old_thumbprint"] != "abc123" {
|
|
t.Errorf("expected old_thumbprint=abc123 in metadata, got: %s", result.Metadata["old_thumbprint"])
|
|
}
|
|
}
|
|
|
|
func TestIISConnector_DeployCertificate_SNIEnabled(t *testing.T) {
|
|
certPEM, keyPEM, chainPEM, err := generateTestCertAndKey()
|
|
if err != nil {
|
|
t.Fatalf("failed to generate test cert: %v", err)
|
|
}
|
|
|
|
executor := newMockExecutor()
|
|
executor.defaultOutput = "OK"
|
|
|
|
connector := NewWithExecutor(&Config{
|
|
Hostname: "web01",
|
|
SiteName: "Default Web Site",
|
|
CertStore: "My",
|
|
Port: 443,
|
|
SNI: true,
|
|
BindingInfo: "test.example.com",
|
|
}, testLogger(), executor)
|
|
|
|
result, err := connector.DeployCertificate(context.Background(), target.DeploymentRequest{
|
|
CertPEM: certPEM,
|
|
KeyPEM: keyPEM,
|
|
ChainPEM: chainPEM,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("DeployCertificate failed: %v", err)
|
|
}
|
|
if !result.Success {
|
|
t.Fatalf("expected success, got: %s", result.Message)
|
|
}
|
|
|
|
// Bundle 10: probe is commands[0], Bundle 5: snapshot is commands[1], import is commands[2], binding is commands[3].
|
|
if len(executor.commands) < 4 {
|
|
t.Fatal("expected at least 4 commands (probe, snapshot, import, binding)")
|
|
}
|
|
bindingCmd := executor.commands[3]
|
|
if !strings.Contains(bindingCmd, "-SslFlags 1") {
|
|
t.Errorf("expected -SslFlags 1 for SNI, got: %s", bindingCmd)
|
|
}
|
|
if result.Metadata["sni"] != "true" {
|
|
t.Error("expected sni=true in metadata")
|
|
}
|
|
}
|
|
|
|
// --- ValidateDeployment tests ---
|
|
|
|
func TestIISConnector_ValidateDeployment_Success(t *testing.T) {
|
|
executor := newMockExecutor()
|
|
executor.responses["Get-WebBinding"] = mockResponse{output: "ABC123DEF456\n", err: nil}
|
|
executor.responses["Get-ChildItem"] = mockResponse{output: "VALID\n", err: nil}
|
|
|
|
connector := NewWithExecutor(&Config{
|
|
Hostname: "web01",
|
|
SiteName: "Default Web Site",
|
|
CertStore: "My",
|
|
Port: 443,
|
|
}, testLogger(), executor)
|
|
|
|
result, err := connector.ValidateDeployment(context.Background(), target.ValidationRequest{
|
|
CertificateID: "mc-test",
|
|
Serial: "123456",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ValidateDeployment failed: %v", err)
|
|
}
|
|
if !result.Valid {
|
|
t.Fatalf("expected valid deployment, got: %s", result.Message)
|
|
}
|
|
if result.Metadata["thumbprint"] != "ABC123DEF456" {
|
|
t.Errorf("expected thumbprint in metadata, got: %s", result.Metadata["thumbprint"])
|
|
}
|
|
if _, ok := result.Metadata["duration_ms"]; !ok {
|
|
t.Error("expected duration_ms in metadata")
|
|
}
|
|
}
|
|
|
|
func TestIISConnector_ValidateDeployment_NoBinding(t *testing.T) {
|
|
executor := newMockExecutor()
|
|
executor.responses["Get-WebBinding"] = mockResponse{output: "NO_BINDING\n", err: nil}
|
|
|
|
connector := NewWithExecutor(&Config{
|
|
Hostname: "web01",
|
|
SiteName: "TestSite",
|
|
CertStore: "My",
|
|
Port: 443,
|
|
}, testLogger(), executor)
|
|
|
|
result, err := connector.ValidateDeployment(context.Background(), target.ValidationRequest{
|
|
CertificateID: "mc-test",
|
|
Serial: "123456",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error when no binding found")
|
|
}
|
|
if result.Valid {
|
|
t.Fatal("expected invalid result")
|
|
}
|
|
if !strings.Contains(err.Error(), "no HTTPS binding found") {
|
|
t.Errorf("expected 'no HTTPS binding found' in error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestIISConnector_ValidateDeployment_CertNotInStore(t *testing.T) {
|
|
executor := newMockExecutor()
|
|
executor.responses["Get-WebBinding"] = mockResponse{output: "DEADBEEF1234\n", err: nil}
|
|
executor.responses["Get-ChildItem"] = mockResponse{output: "NOT_FOUND\n", err: nil}
|
|
|
|
connector := NewWithExecutor(&Config{
|
|
Hostname: "web01",
|
|
SiteName: "Default Web Site",
|
|
CertStore: "My",
|
|
Port: 443,
|
|
}, testLogger(), executor)
|
|
|
|
result, err := connector.ValidateDeployment(context.Background(), target.ValidationRequest{
|
|
CertificateID: "mc-test",
|
|
Serial: "123456",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error when cert not in store")
|
|
}
|
|
if result.Valid {
|
|
t.Fatal("expected invalid result")
|
|
}
|
|
if result.Metadata["status"] != "not_found" {
|
|
t.Errorf("expected status=not_found in metadata, got: %s", result.Metadata["status"])
|
|
}
|
|
}
|
|
|
|
func TestIISConnector_ValidateDeployment_CertExpired(t *testing.T) {
|
|
executor := newMockExecutor()
|
|
executor.responses["Get-WebBinding"] = mockResponse{output: "DEADBEEF1234\n", err: nil}
|
|
executor.responses["Get-ChildItem"] = mockResponse{output: "EXPIRED\n", err: nil}
|
|
|
|
connector := NewWithExecutor(&Config{
|
|
Hostname: "web01",
|
|
SiteName: "Default Web Site",
|
|
CertStore: "My",
|
|
Port: 443,
|
|
}, testLogger(), executor)
|
|
|
|
result, err := connector.ValidateDeployment(context.Background(), target.ValidationRequest{
|
|
CertificateID: "mc-test",
|
|
Serial: "123456",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error when cert is expired")
|
|
}
|
|
if result.Valid {
|
|
t.Fatal("expected invalid result")
|
|
}
|
|
if result.Metadata["status"] != "expired" {
|
|
t.Errorf("expected status=expired in metadata, got: %s", result.Metadata["status"])
|
|
}
|
|
}
|
|
|
|
func TestIISConnector_ValidateDeployment_QueryFails(t *testing.T) {
|
|
executor := newMockExecutor()
|
|
executor.responses["Get-WebBinding"] = mockResponse{
|
|
output: "Permission denied",
|
|
err: fmt.Errorf("exit status 1"),
|
|
}
|
|
|
|
connector := NewWithExecutor(&Config{
|
|
Hostname: "web01",
|
|
SiteName: "Default Web Site",
|
|
CertStore: "My",
|
|
Port: 443,
|
|
}, testLogger(), executor)
|
|
|
|
result, err := connector.ValidateDeployment(context.Background(), target.ValidationRequest{
|
|
CertificateID: "mc-test",
|
|
Serial: "123456",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error when query fails")
|
|
}
|
|
if result.Valid {
|
|
t.Fatal("expected invalid result")
|
|
}
|
|
}
|
|
|
|
// --- PFX conversion tests (pure Go crypto, runs on any OS) ---
|
|
|
|
func TestCreatePFX_Success(t *testing.T) {
|
|
certPEM, keyPEM, chainPEM, err := generateTestCertAndKey()
|
|
if err != nil {
|
|
t.Fatalf("failed to generate test cert: %v", err)
|
|
}
|
|
|
|
pfxData, err := certutil.CreatePFX(certPEM, keyPEM, chainPEM, "testpassword")
|
|
if err != nil {
|
|
t.Fatalf("createPFX failed: %v", err)
|
|
}
|
|
|
|
if len(pfxData) == 0 {
|
|
t.Fatal("expected non-empty PFX data")
|
|
}
|
|
|
|
// Verify PFX is parseable
|
|
_, _, _, err = pkcs12.DecodeChain(pfxData, "testpassword")
|
|
if err != nil {
|
|
t.Fatalf("PFX data is not valid PKCS#12: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCreatePFX_NoChain(t *testing.T) {
|
|
certPEM, keyPEM, _, err := generateTestCertAndKey()
|
|
if err != nil {
|
|
t.Fatalf("failed to generate test cert: %v", err)
|
|
}
|
|
|
|
pfxData, err := certutil.CreatePFX(certPEM, keyPEM, "", "testpassword")
|
|
if err != nil {
|
|
t.Fatalf("createPFX with no chain failed: %v", err)
|
|
}
|
|
|
|
if len(pfxData) == 0 {
|
|
t.Fatal("expected non-empty PFX data")
|
|
}
|
|
}
|
|
|
|
func TestCreatePFX_InvalidCert(t *testing.T) {
|
|
_, keyPEM, _, err := generateTestCertAndKey()
|
|
if err != nil {
|
|
t.Fatalf("failed to generate test key: %v", err)
|
|
}
|
|
|
|
_, err = certutil.CreatePFX("not a valid cert", keyPEM, "", "password")
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid cert PEM")
|
|
}
|
|
}
|
|
|
|
func TestCreatePFX_InvalidKey(t *testing.T) {
|
|
certPEM, _, _, err := generateTestCertAndKey()
|
|
if err != nil {
|
|
t.Fatalf("failed to generate test cert: %v", err)
|
|
}
|
|
|
|
_, err = certutil.CreatePFX(certPEM, "not a valid key", "", "password")
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid key PEM")
|
|
}
|
|
}
|
|
|
|
// --- Thumbprint tests ---
|
|
|
|
func TestComputeThumbprint_Success(t *testing.T) {
|
|
certPEM, _, _, err := generateTestCertAndKey()
|
|
if err != nil {
|
|
t.Fatalf("failed to generate test cert: %v", err)
|
|
}
|
|
|
|
thumbprint, err := certutil.ComputeThumbprint(certPEM)
|
|
if err != nil {
|
|
t.Fatalf("computeThumbprint failed: %v", err)
|
|
}
|
|
|
|
// SHA-1 = 20 bytes = 40 hex chars
|
|
if len(thumbprint) != 40 {
|
|
t.Errorf("expected 40-char thumbprint, got %d chars: %s", len(thumbprint), thumbprint)
|
|
}
|
|
|
|
// Should be uppercase hex
|
|
if thumbprint != strings.ToUpper(thumbprint) {
|
|
t.Errorf("thumbprint should be uppercase, got: %s", thumbprint)
|
|
}
|
|
}
|
|
|
|
func TestComputeThumbprint_InvalidPEM(t *testing.T) {
|
|
_, err := certutil.ComputeThumbprint("not a valid pem")
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid PEM")
|
|
}
|
|
}
|
|
|
|
func TestComputeThumbprint_EmptyString(t *testing.T) {
|
|
_, err := certutil.ComputeThumbprint("")
|
|
if err == nil {
|
|
t.Fatal("expected error for empty string")
|
|
}
|
|
}
|
|
|
|
// --- Validation helper tests ---
|
|
|
|
func TestValidateIISName_Valid(t *testing.T) {
|
|
tests := []string{
|
|
"Default Web Site",
|
|
"My",
|
|
"WebHosting",
|
|
"site-01",
|
|
"my_site.prod",
|
|
"Test 123",
|
|
}
|
|
|
|
for _, name := range tests {
|
|
t.Run(name, func(t *testing.T) {
|
|
if err := validateIISName(name, "test_field"); err != nil {
|
|
t.Errorf("expected valid name %q, got error: %v", name, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateIISName_Invalid(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
}{
|
|
{"empty", ""},
|
|
{"semicolon", "My;Store"},
|
|
{"dollar", "My$Store"},
|
|
{"backtick", "My`Store"},
|
|
{"pipe", "My|Store"},
|
|
{"ampersand", "My&Store"},
|
|
{"parentheses", "My(Store)"},
|
|
{"quotes", `My"Store"`},
|
|
{"angle_brackets", "My<Store>"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if err := validateIISName(tt.input, "test_field"); err == nil {
|
|
t.Errorf("expected error for name %q", tt.input)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateIISName_TooLong(t *testing.T) {
|
|
longName := strings.Repeat("a", 257)
|
|
if err := validateIISName(longName, "test_field"); err == nil {
|
|
t.Fatal("expected error for name exceeding 256 chars")
|
|
}
|
|
}
|
|
|
|
// --- Random password generation ---
|
|
|
|
func TestGenerateRandomPassword(t *testing.T) {
|
|
pw, err := certutil.GenerateRandomPassword(32)
|
|
if err != nil {
|
|
t.Fatalf("generateRandomPassword failed: %v", err)
|
|
}
|
|
if len(pw) != 32 {
|
|
t.Errorf("expected 32-char password, got %d", len(pw))
|
|
}
|
|
|
|
// Verify it only contains allowed characters
|
|
for _, c := range pw {
|
|
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) {
|
|
t.Errorf("unexpected character in password: %c", c)
|
|
}
|
|
}
|
|
|
|
// Verify two passwords are different (probabilistic but reliable)
|
|
pw2, _ := certutil.GenerateRandomPassword(32)
|
|
if pw == pw2 {
|
|
t.Error("two generated passwords should be different")
|
|
}
|
|
}
|
|
|
|
// --- WinRM mode tests ---
|
|
|
|
func TestIISConnector_ValidateConfig_WinRMMode(t *testing.T) {
|
|
executor := newMockExecutor()
|
|
executor.responses["Get-Website"] = mockResponse{output: "Default Web Site\n", err: nil}
|
|
executor.responses["Test-Path"] = mockResponse{output: "True\n", err: nil}
|
|
|
|
cfg := Config{
|
|
SiteName: "Default Web Site",
|
|
CertStore: "My",
|
|
Mode: "winrm",
|
|
WinRM: WinRMConfig{
|
|
Host: "iis-server.example.com",
|
|
Port: 5985,
|
|
Username: "Administrator",
|
|
Password: "P@ssw0rd",
|
|
},
|
|
}
|
|
|
|
// WinRM mode should NOT check for powershell.exe locally
|
|
connector := NewWithExecutor(&cfg, testLogger(), executor)
|
|
rawConfig, _ := json.Marshal(cfg)
|
|
|
|
err := connector.ValidateConfig(context.Background(), rawConfig)
|
|
if err != nil {
|
|
t.Fatalf("ValidateConfig failed in WinRM mode: %v", err)
|
|
}
|
|
|
|
// Verify PowerShell commands were executed via the executor (not locally)
|
|
if len(executor.commands) < 2 {
|
|
t.Fatalf("expected at least 2 executor commands, got %d", len(executor.commands))
|
|
}
|
|
}
|
|
|
|
func TestIISConnector_ValidateConfig_InvalidMode(t *testing.T) {
|
|
connector := NewWithExecutor(&Config{}, testLogger(), newMockExecutor())
|
|
cfg := Config{
|
|
SiteName: "Default Web Site",
|
|
CertStore: "My",
|
|
Mode: "invalid",
|
|
}
|
|
rawConfig, _ := json.Marshal(cfg)
|
|
|
|
err := connector.ValidateConfig(context.Background(), rawConfig)
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid mode")
|
|
}
|
|
if !strings.Contains(err.Error(), "unsupported mode") {
|
|
t.Errorf("expected 'unsupported mode' in error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestIISConnector_DeployCertificate_WinRMMode(t *testing.T) {
|
|
executor := newMockExecutor()
|
|
executor.defaultOutput = "OK"
|
|
|
|
cfg := Config{
|
|
Hostname: "iis-server.example.com",
|
|
SiteName: "Default Web Site",
|
|
CertStore: "My",
|
|
Port: 443,
|
|
IPAddress: "*",
|
|
Mode: "winrm",
|
|
}
|
|
|
|
connector := NewWithExecutor(&cfg, testLogger(), executor)
|
|
certPEM, keyPEM, _, err := generateTestCertAndKey()
|
|
if err != nil {
|
|
t.Fatalf("failed to generate test cert: %v", err)
|
|
}
|
|
|
|
result, err := connector.DeployCertificate(context.Background(), target.DeploymentRequest{
|
|
CertPEM: certPEM,
|
|
KeyPEM: keyPEM,
|
|
ChainPEM: "",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("DeployCertificate in WinRM mode failed: %v", err)
|
|
}
|
|
if !result.Success {
|
|
t.Fatalf("expected success, got: %s", result.Message)
|
|
}
|
|
|
|
// Verify the import script used base64 encoding (WinRM mode)
|
|
foundBase64Import := false
|
|
for _, cmd := range executor.commands {
|
|
if strings.Contains(cmd, "FromBase64String") && strings.Contains(cmd, "Import-PfxCertificate") {
|
|
foundBase64Import = true
|
|
break
|
|
}
|
|
}
|
|
if !foundBase64Import {
|
|
t.Error("WinRM mode should use base64-encoded PFX transfer, but no FromBase64String found in commands")
|
|
}
|
|
|
|
// Verify remote temp file cleanup is in the script
|
|
foundCleanup := false
|
|
for _, cmd := range executor.commands {
|
|
if strings.Contains(cmd, "Remove-Item") && strings.Contains(cmd, "finally") {
|
|
foundCleanup = true
|
|
break
|
|
}
|
|
}
|
|
if !foundCleanup {
|
|
t.Error("WinRM mode should include remote temp file cleanup (try/finally Remove-Item)")
|
|
}
|
|
}
|
|
|
|
func TestIISConnector_New_WinRMMode_MissingHost(t *testing.T) {
|
|
cfg := Config{
|
|
Mode: "winrm",
|
|
WinRM: WinRMConfig{
|
|
Username: "admin",
|
|
Password: "pass",
|
|
},
|
|
}
|
|
_, err := New(&cfg, testLogger())
|
|
if err == nil {
|
|
t.Fatal("expected error for missing WinRM host")
|
|
}
|
|
if !strings.Contains(err.Error(), "winrm_host is required") {
|
|
t.Errorf("expected 'winrm_host is required' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestIISConnector_New_WinRMMode_MissingUsername(t *testing.T) {
|
|
cfg := Config{
|
|
Mode: "winrm",
|
|
WinRM: WinRMConfig{
|
|
Host: "server.example.com",
|
|
Password: "pass",
|
|
},
|
|
}
|
|
_, err := New(&cfg, testLogger())
|
|
if err == nil {
|
|
t.Fatal("expected error for missing WinRM username")
|
|
}
|
|
if !strings.Contains(err.Error(), "winrm_username is required") {
|
|
t.Errorf("expected 'winrm_username is required' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestIISConnector_New_WinRMMode_MissingPassword(t *testing.T) {
|
|
cfg := Config{
|
|
Mode: "winrm",
|
|
WinRM: WinRMConfig{
|
|
Host: "server.example.com",
|
|
Username: "admin",
|
|
},
|
|
}
|
|
_, err := New(&cfg, testLogger())
|
|
if err == nil {
|
|
t.Fatal("expected error for missing WinRM password")
|
|
}
|
|
if !strings.Contains(err.Error(), "winrm_password is required") {
|
|
t.Errorf("expected 'winrm_password is required' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestIISConnector_New_InvalidMode(t *testing.T) {
|
|
cfg := Config{Mode: "ssh"}
|
|
_, err := New(&cfg, testLogger())
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid mode")
|
|
}
|
|
if !strings.Contains(err.Error(), "unsupported IIS connector mode") {
|
|
t.Errorf("expected 'unsupported IIS connector mode' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestIISConnector_New_DefaultLocalMode(t *testing.T) {
|
|
cfg := Config{} // No mode specified — should default to local
|
|
connector, err := New(&cfg, testLogger())
|
|
if err != nil {
|
|
t.Fatalf("New() with default mode failed: %v", err)
|
|
}
|
|
if connector == nil {
|
|
t.Fatal("expected non-nil connector")
|
|
}
|
|
}
|
|
|
|
func TestWinRMConfig_DefaultPorts(t *testing.T) {
|
|
// HTTP default: 5985
|
|
cfg := &WinRMConfig{
|
|
Host: "server.example.com",
|
|
Username: "admin",
|
|
Password: "pass",
|
|
}
|
|
exec, err := newWinRMExecutor(cfg)
|
|
if err != nil {
|
|
t.Fatalf("newWinRMExecutor failed: %v", err)
|
|
}
|
|
if exec == nil {
|
|
t.Fatal("expected non-nil executor")
|
|
}
|
|
|
|
// HTTPS default: 5986
|
|
cfgHTTPS := &WinRMConfig{
|
|
Host: "server.example.com",
|
|
Username: "admin",
|
|
Password: "pass",
|
|
UseHTTPS: true,
|
|
Insecure: true,
|
|
}
|
|
execHTTPS, err := newWinRMExecutor(cfgHTTPS)
|
|
if err != nil {
|
|
t.Fatalf("newWinRMExecutor (HTTPS) failed: %v", err)
|
|
}
|
|
if execHTTPS == nil {
|
|
t.Fatal("expected non-nil HTTPS executor")
|
|
}
|
|
}
|
|
|
|
// --- Top-10 fix #4: default-deadline ctx wrapper for PowerShell exec calls ---
|
|
//
|
|
// These two tests pin realExecutor's safety-net behavior: a default ExecDeadline
|
|
// caps each subprocess invocation when the caller's ctx has no deadline of its
|
|
// own; a tighter caller-supplied deadline always wins. The actual subprocess
|
|
// is irrelevant — on Linux/macOS runners powershell.exe is missing and
|
|
// exec.Cmd fails fast at Start(); on Windows runners the wrapper's ctx
|
|
// deadline cancels the running PowerShell process. Either path returns well
|
|
// under 500ms; what we assert is the contract (fast return + error), which
|
|
// regresses if a future refactor removes the wrapper.
|
|
|
|
func TestIIS_RealExecutor_AttachesDefaultDeadlineWhenCallerHasNone(t *testing.T) {
|
|
e := &realExecutor{deadline: 100 * time.Millisecond}
|
|
start := time.Now()
|
|
_, err := e.Execute(context.Background(), "Start-Sleep -Seconds 5")
|
|
elapsed := time.Since(start)
|
|
if elapsed > 500*time.Millisecond {
|
|
t.Errorf("expected fast return (default deadline = 100ms), took %v: err=%v", elapsed, err)
|
|
}
|
|
if err == nil {
|
|
t.Error("expected an error (context.DeadlineExceeded on Windows / powershell.exe missing on Linux)")
|
|
}
|
|
}
|
|
|
|
func TestIIS_RealExecutor_RespectsCallerDeadlineWhenSet(t *testing.T) {
|
|
e := &realExecutor{deadline: 10 * time.Second} // long fallback
|
|
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
|
defer cancel()
|
|
start := time.Now()
|
|
_, _ = e.Execute(ctx, "Start-Sleep -Seconds 5")
|
|
elapsed := time.Since(start)
|
|
// Caller's tight 50ms deadline must win over the executor's 10s fallback.
|
|
if elapsed > 500*time.Millisecond {
|
|
t.Errorf("expected caller's tight 50ms deadline to fire fast, took %v", elapsed)
|
|
}
|
|
}
|
|
|
|
func TestIIS_RealExecutor_NoDeadlineWiredWhenZero(t *testing.T) {
|
|
// deadline=0 means "no fallback wrapper" — caller is fully in charge.
|
|
// Pass a tight caller deadline so the test still terminates fast.
|
|
e := &realExecutor{deadline: 0}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
|
defer cancel()
|
|
start := time.Now()
|
|
_, _ = e.Execute(ctx, "Start-Sleep -Seconds 5")
|
|
elapsed := time.Since(start)
|
|
if elapsed > 500*time.Millisecond {
|
|
t.Errorf("expected caller deadline to bound the call, took %v", elapsed)
|
|
}
|
|
}
|
|
|
|
func TestIIS_New_DefaultsExecDeadlineTo60s(t *testing.T) {
|
|
// New() applies the 60s default when ExecDeadline is unset. The local
|
|
// realExecutor captures the value at construction.
|
|
cfg := &Config{
|
|
SiteName: "Default Web Site",
|
|
CertStore: "My",
|
|
Mode: "winrm", // winrm path skips realExecutor (uses winrmExecutor); won't fail-fast
|
|
WinRM: WinRMConfig{
|
|
Host: "server.example.com",
|
|
Username: "admin",
|
|
Password: "pass",
|
|
},
|
|
}
|
|
c, err := New(cfg, testLogger())
|
|
if err != nil {
|
|
t.Fatalf("New failed: %v", err)
|
|
}
|
|
if c.config.ExecDeadline != 60*time.Second {
|
|
t.Errorf("expected default ExecDeadline=60s, got %v", c.config.ExecDeadline)
|
|
}
|
|
}
|
|
|
|
func TestIIS_New_RespectsExplicitExecDeadline(t *testing.T) {
|
|
cfg := &Config{
|
|
SiteName: "Default Web Site",
|
|
CertStore: "My",
|
|
Mode: "winrm",
|
|
ExecDeadline: 10 * time.Minute,
|
|
WinRM: WinRMConfig{
|
|
Host: "server.example.com",
|
|
Username: "admin",
|
|
Password: "pass",
|
|
},
|
|
}
|
|
c, err := New(cfg, testLogger())
|
|
if err != nil {
|
|
t.Fatalf("New failed: %v", err)
|
|
}
|
|
if c.config.ExecDeadline != 10*time.Minute {
|
|
t.Errorf("expected ExecDeadline=10m, got %v", c.config.ExecDeadline)
|
|
}
|
|
}
|