mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-10 15:08:51 +00:00
5dc698307b
Mechanical sed across the main go.mod's module declaration, the f5-mock-icontrol
sub-module's go.mod, every Go file's import path (361 files), and a rebuild of
the checked-in f5-mock-icontrol binary so its embedded build-info reflects the
new module path. No behavior change.
Choice B from cowork/transfer-certctl-to-org.md, executed 2026-05-04. Choice A
(keep module path declared as github.com/shankar0123/certctl regardless of
repo URL) shipped on the day of the org transfer (2026-05-03) since we had no
external Go consumers; this commit closes that deferral.
Backward-compat: GitHub HTTP redirects continue to forward
github.com/shankar0123/certctl → github.com/certctl-io/certctl at the URL
level, but Go's module proxy uses the path declared in go.mod as the
canonical name. Pre-fix, anyone trying `go get github.com/certctl-io/certctl/...`
hit a "module path mismatch" error because go.mod said
github.com/shankar0123/certctl and the URL they fetched it from said
certctl-io/certctl. Post-fix, the canonical name and the URL agree, so
go get / go install / external Go consumers / Go-tooling integrations
work cleanly via either the new path (preferred) or the old path (which
redirects and Go follows the redirect for source fetch).
Anyone still importing the old path inside their own code keeps working
provided they update their go.mod's `require` line to match — the module
path declared in their consumer's go.sum / go.mod is the authoritative
import name, so a mass sed across their import statements is the migration
on the consumer side. No external consumers exist today.
Diff shape:
361 *.go files — import path replacement only
2 go.mod — module declaration replacement only
1 binary — deploy/test/f5-mock-icontrol/f5-mock-icontrol rebuilt
so embedded build-info reflects the new path (8618965 vs
8618933 bytes; 32-byte diff is the build-info change)
Total: 364 files, 730 insertions / 730 deletions, net-zero size, pure
mechanical substitution.
Verification:
gofmt: 17 files needed re-alignment after sed (the new path is one char
shorter than the old, so column-aligned import groups drifted). Applied
`gofmt -w` to fix.
go mod tidy: clean exit on both modules.
go vet ./...: clean exit.
go build ./...: clean exit.
go test -short -count=1 on representative packages: all green
(internal/domain, internal/validation, internal/crypto, internal/crypto/signer,
cmd/agent). Test output now reads `ok github.com/certctl-io/certctl/...`
confirming the module path resolves correctly.
binary: f5-mock-icontrol rebuilt; `strings | grep shankar0123` returns
nothing; `strings | grep certctl-io/certctl` shows the new module path
embedded in build-info.
Files intentionally NOT touched in this commit:
README.md / CHANGELOG.md / docs/ / etc. — already swept to certctl-io
URLs in commit bc6039a (the post-transfer URL refresh). This commit is
purely the Go-tooling layer.
Scarf pixels (`shankar0123.docker.scarf.sh/...`) — Scarf-account
namespace, not a Go import or GitHub repo URL. Stays.
This is a non-blocking, non-customer-impacting change. Operators pulling
container images, running `make verify`, hitting the API, or installing the
agent see no functional difference. Only Go-tooling consumers (none today)
are affected, and they're enabled — not broken — by this commit.
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/certctl-io/certctl/internal/connector/target"
|
|
"github.com/certctl-io/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)
|
|
}
|
|
}
|