Files
certctl/internal/connector/target/validate_only_smoke_test.go
T
shankar0123 36d79cd1ff feat(f5,iis): explicit ValidateOnly + leverage existing transactional rollback
Phase 8 of the deploy-hardening I master bundle. F5 + IIS already
have transactional / explicit-backup-restore rollback semantics
in their DeployCertificate paths. Phase 8 adds the explicit
ValidateOnly dry-run probe that operators use to preview a deploy
without touching the live cert.

F5 (validate_only.go):
- ValidateOnly probes the iControl REST API via Authenticate.
  Cheap (no F5 transaction created) + cached after first success.
  Failure surfaces as a wrapped error so operators see the actual
  cause (auth provider down, invalid creds, BIG-IP unreachable,
  etc.). nil client returns ErrValidateOnlyNotSupported.
- A true cert-bind dry-run requires F5's no-commit transaction
  mode (v17.5+); V3-Pro can add per-version dispatch. V2 ships
  the reachability probe as the load-bearing safety check.
- 5 new tests in validate_only_test.go covering: auth-success,
  auth-fail wrapped, nil-client sentinel, error-message contains
  BIG-IP context, recoverable auth-fail surfaces provider info.

IIS (validate_only.go):
- ValidateOnly runs `Get-WebSite -Name <SiteName>` via the
  injected PowerShellExecutor. Confirms the IIS PS module is
  loaded AND the site exists AND the agent has admin privileges.
  Failure here surfaces the actual PowerShell stderr (site not
  found / module missing / access denied).
- A true cert-bind dry-run would need IIS to expose a no-commit
  New-WebBinding (it doesn't); V3-Pro can extend with a
  temp-install + immediate-remove. V2 ships the permission +
  module probe as the load-bearing check.
- 5 new tests in validate_only_test.go covering: get-website
  succeeds, get-website fails, nil-executor sentinel, site-name
  quoting (handles spaces in 'Default Web Site'), output-context
  in error.

Smoke test connectorsAtPhase3 list shrunk from 10 to 7 entries
(f5 + iis + postfix removed). Caddy stays in (file-mode returns
sentinel; api-mode is real-impl). Envoy + Traefik stay in (no
validate-with-target command exists for either). javakeystore +
k8ssecret + ssh + wincertstore stay in pending Phase 9.

Coverage: F5 holds at ≥85%; IIS holds at ≥85%. Race detector
clean. golangci-lint v2.11.4 clean.

Phase 9 next: SSH + WinCertStore + JavaKeystore + K8s — the
non-file-server connectors.
2026-04-30 15:16:11 +00:00

108 lines
5.2 KiB
Go

package target_test
// Phase 3 of the deploy-hardening I master bundle: per-connector
// regression smoke pinning the default ValidateOnly stub returns
// the sentinel for every one of the 13 connectors. This test lives
// in target_test (external test package) so it can import each
// connector concretely + assert the interface contract.
//
// As Phases 4-9 replace each connector's stub with a real
// validate-with-the-target implementation, the corresponding
// per-connector entry in TestEveryConnectorDefaultsToSentinel
// MUST be deleted (or the test will fail because the real
// implementation no longer returns the sentinel). That deletion
// IS the bookkeeping that the operator-visible bit + behavior
// change are wired together.
import (
"context"
"encoding/json"
"errors"
"testing"
"github.com/shankar0123/certctl/internal/connector/target"
// apache removed Phase 5 — real ValidateOnly implementation now in apache.go.
"github.com/shankar0123/certctl/internal/connector/target/caddy"
"github.com/shankar0123/certctl/internal/connector/target/envoy"
// f5 removed Phase 8 — real ValidateOnly implementation now in validate_only.go.
// haproxy removed Phase 6 — real ValidateOnly implementation now in haproxy.go.
// iis removed Phase 8 — real ValidateOnly implementation now in validate_only.go.
"github.com/shankar0123/certctl/internal/connector/target/javakeystore"
"github.com/shankar0123/certctl/internal/connector/target/k8ssecret"
// nginx removed Phase 4 — real ValidateOnly implementation now in nginx.go.
// postfix removed Phase 7 — real ValidateOnly implementation now in postfix.go.
"github.com/shankar0123/certctl/internal/connector/target/ssh"
"github.com/shankar0123/certctl/internal/connector/target/traefik"
"github.com/shankar0123/certctl/internal/connector/target/wincertstore"
)
// connectorsAtPhase3 is the canonical list of connectors that, as
// of Phase 3, return ErrValidateOnlyNotSupported from
// ValidateOnly. Each entry is a (name, factory) tuple; the factory
// returns a target.Connector via the connector's bare-NewConnector
// constructor pattern. As Phases 4-9 land, the corresponding
// connector is REMOVED from this list — its real ValidateOnly
// implementation is then exercised in the per-connector test
// suite, NOT here.
//
// CI guard rationale: a future PR that adds a 14th connector
// without wiring ValidateOnly fails this test (the sentinel
// contract is not satisfied). A future PR that implements a real
// ValidateOnly for, say, NGINX, but forgets to remove its entry
// from this list, fails this test (real impl no longer returns
// the sentinel). Both are the load-bearing bookkeeping protections.
var connectorsAtPhase3 = []struct {
name string
// new returns a fresh Connector instance. The default
// ValidateOnly stub doesn't dereference any field on the
// receiver, so a zero-value &pkg.Connector{} is sufficient
// to satisfy the interface and exercise the sentinel return.
// Phases 4-9 introduce real validate-with-the-target impls
// that DO read fields; those connectors will need a populated
// constructor here OR (more likely) be removed from this list
// entirely and exercised in their own per-connector test
// suite.
new func() target.Connector
}{
// apache removed Phase 5 — its ValidateOnly is now the real
// implementation; tested directly in apache/apache_atomic_test.go.
// caddy: file mode returns sentinel (no validate-with-target);
// api mode is real-impl. Empty Connector hits the file-mode path.
{"caddy", func() target.Connector { return &caddy.Connector{} }},
// envoy: no validate-with-target command exists; always sentinel.
{"envoy", func() target.Connector { return &envoy.Connector{} }},
// f5 removed Phase 8 — Authenticate-probe real impl.
// haproxy removed Phase 6 — `haproxy -c -f` real impl.
// iis removed Phase 8 — Get-WebSite probe real impl.
{"javakeystore", func() target.Connector { return &javakeystore.Connector{} }},
{"k8ssecret", func() target.Connector { return &k8ssecret.Connector{} }},
// nginx removed Phase 4 — `nginx -t` real impl.
// postfix removed Phase 7 — `postfix check` / `doveconf -n` real impl.
{"ssh", func() target.Connector { return &ssh.Connector{} }},
// traefik: no validate-with-target command exists; always sentinel.
{"traefik", func() target.Connector { return &traefik.Connector{} }},
{"wincertstore", func() target.Connector { return &wincertstore.Connector{} }},
}
func TestEveryConnectorDefaultsToSentinel(t *testing.T) {
// Expected list size shrinks as Phases 4-9 land their real
// ValidateOnly implementations. Phase 4 removed nginx.
const expectedAtCurrentPhase = 7
if len(connectorsAtPhase3) != expectedAtCurrentPhase {
t.Fatalf("connectors-at-phase list = %d entries, want %d (drift in the 13-connector inventory)", len(connectorsAtPhase3), expectedAtCurrentPhase)
}
for _, c := range connectorsAtPhase3 {
t.Run(c.name, func(t *testing.T) {
conn := c.new()
err := conn.ValidateOnly(context.Background(), target.DeploymentRequest{
CertPEM: "ignored-by-stub",
ChainPEM: "ignored",
TargetConfig: json.RawMessage(`{}`),
})
if !errors.Is(err, target.ErrValidateOnlyNotSupported) {
t.Errorf("got %v, want ErrValidateOnlyNotSupported", err)
}
})
}
}