mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 16:01:30 +00:00
41a8f5853e
M.F5 closes H-001; M.Email closes H-003; M.SSH partial-closes H-002; M.Cloud (H-004) deferred.
M.F5 (~430 LoC f5_realclient_test.go):
Coverage: 44.6% -> 90.1% (+45.5pp; +5.1 above 85% target)
Bypasses existing F5Client-interface mock; exercises every realF5Client
HTTP method end-to-end against httptest.Server with canned iControl REST
responses. 401-retry path verified. Per-fn ALL previously-0% lifted to
88-100%. Plus context-cancel test.
M.SSH (~150 LoC ssh_realclient_test.go) PARTIAL-CLOSED:
Coverage: 55.2% -> 71.6% (+16.4pp; below 85% target)
Covers buildAuthMethods all branches + WriteFile/Execute/StatFile
not-connected guards + Close idempotency.
Connect() ~50 LoC needs embedded golang.org/x/crypto/ssh server fixture
(~1000 LoC test infrastructure). Tracked as Bundle M.SSH-extended.
M.Email (~340 LoC email_failure_test.go):
Coverage: 39.7% -> 70.5% (+30.8pp; +0.5 above 70% target)
Hand-rolled minimal SMTP server (responds to EHLO/AUTH/MAIL/RCPT/DATA/
QUIT with canned 2xx/3xx/5xx responses based on per-test failOn map).
Tests:
- Header-injection (CWE-113): CR/LF/NUL in From/To/Subject reject
before any SMTP I/O (6 tests across sendEmail + sendHTMLEmail)
- Connection-refused for both sendEmail and sendHTMLEmail
- SendAlert / SendEvent full SMTP transactions (happy path)
- Server-side failures: RCPT 550, DATA 554
- AUTH PLAIN happy + 535-failure
M.Cloud (H-004) DEFERRED:
AzureKV 41.2% / GCP-SM 43.1%. Same M.F5 approach (httptest.Server +
OAuth2 token endpoint mock) is straightforward but ~600 LoC tests +
~200 LoC mock infrastructure exceeds session budget. Tracked as
Bundle M.Cloud-extended.
Verification:
go vet ./internal/connector/{target/f5,target/ssh,notifier/email}/... clean
gofmt -l clean
staticcheck -checks all clean
go test -short -count=1 PASS
F5 90.1% Email 70.5% SSH 71.6%
Audit deliverables:
findings.yaml: -0008 (F5) + -0010 (Email) -> closed; -0009 (SSH) ->
partial_closed; -0011 (Cloud) retained as deferred
gap-backlog.md: strikethroughs + Bundle M closure-log entry covering all 4 sub-batches
coverage-matrix.md: 3 new rows for F5/SSH/Email at post-Bundle-M coverage
closure-plan.md: Bundle M [~] with per-sub-batch status breakdown
CHANGELOG.md: [unreleased] Bundle M entry
229 lines
6.8 KiB
Go
229 lines
6.8 KiB
Go
package ssh
|
|
|
|
// Bundle M.SSH (Coverage Audit Closure) — SSH/SFTP target connector
|
|
// realclient failure-mode coverage. Closes finding H-002.
|
|
//
|
|
// The existing ssh_test.go tests the Connector layer via the SSHClient
|
|
// interface using a hand-rolled mockSSHClient. The realSSHClient
|
|
// implementation has 6 methods at 0% coverage (Connect, buildAuthMethods,
|
|
// WriteFile, Execute, StatFile, Close).
|
|
//
|
|
// Connect requires a live SSH server, so we don't test it here — the test
|
|
// for Connect is a manual deploy-time test (Part 44 in
|
|
// docs/testing-guide.md). Bundle M instead pins the testable surface:
|
|
//
|
|
// - buildAuthMethods: every config branch (password, key from PEM, key
|
|
// from path, key with passphrase, no auth, unsupported method, missing
|
|
// key file)
|
|
// - WriteFile / Execute / StatFile: not-connected guard (nil-client paths)
|
|
// - Close: idempotent (multiple calls)
|
|
// - New: constructor + applyDefaults
|
|
|
|
import (
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/x509"
|
|
"encoding/pem"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// quietSSHLogger returns a slog.Logger writing to io.Discard at error level.
|
|
func quietSSHLogger() *slog.Logger {
|
|
return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError}))
|
|
}
|
|
|
|
// generateTestPEM returns a PEM-encoded ECDSA P-256 private key suitable
|
|
// for ssh.ParsePrivateKey.
|
|
func generateTestPEM(t *testing.T) []byte {
|
|
t.Helper()
|
|
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("gen key: %v", err)
|
|
}
|
|
der, err := x509.MarshalPKCS8PrivateKey(priv)
|
|
if err != nil {
|
|
t.Fatalf("marshal pkcs8: %v", err)
|
|
}
|
|
return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// New / applyDefaults
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestNew_AppliesDefaults(t *testing.T) {
|
|
cfg := &Config{Host: "h", User: "u"}
|
|
conn, err := New(cfg, quietSSHLogger())
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
if conn == nil {
|
|
t.Fatal("New returned nil connector")
|
|
}
|
|
if cfg.Port != 22 {
|
|
t.Errorf("Port default = %d; want 22", cfg.Port)
|
|
}
|
|
if cfg.AuthMethod != "key" {
|
|
t.Errorf("AuthMethod default = %q; want 'key'", cfg.AuthMethod)
|
|
}
|
|
if cfg.CertMode != "0644" {
|
|
t.Errorf("CertMode default = %q; want '0644'", cfg.CertMode)
|
|
}
|
|
if cfg.KeyMode != "0600" {
|
|
t.Errorf("KeyMode default = %q; want '0600'", cfg.KeyMode)
|
|
}
|
|
if cfg.Timeout != 30 {
|
|
t.Errorf("Timeout default = %d; want 30", cfg.Timeout)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// buildAuthMethods
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestBuildAuthMethods_Password(t *testing.T) {
|
|
c := &realSSHClient{config: &Config{
|
|
AuthMethod: "password",
|
|
Password: "secret",
|
|
}}
|
|
methods, err := c.buildAuthMethods()
|
|
if err != nil {
|
|
t.Fatalf("buildAuthMethods: %v", err)
|
|
}
|
|
if len(methods) != 1 {
|
|
t.Errorf("expected 1 auth method, got %d", len(methods))
|
|
}
|
|
}
|
|
|
|
func TestBuildAuthMethods_KeyInline(t *testing.T) {
|
|
pemData := generateTestPEM(t)
|
|
c := &realSSHClient{config: &Config{
|
|
AuthMethod: "key",
|
|
PrivateKey: string(pemData),
|
|
}}
|
|
methods, err := c.buildAuthMethods()
|
|
if err != nil {
|
|
t.Fatalf("buildAuthMethods: %v", err)
|
|
}
|
|
if len(methods) != 1 {
|
|
t.Errorf("expected 1 auth method, got %d", len(methods))
|
|
}
|
|
}
|
|
|
|
func TestBuildAuthMethods_KeyFromPath(t *testing.T) {
|
|
dir := t.TempDir()
|
|
keyPath := filepath.Join(dir, "id_ecdsa")
|
|
if err := os.WriteFile(keyPath, generateTestPEM(t), 0o600); err != nil {
|
|
t.Fatalf("write key: %v", err)
|
|
}
|
|
c := &realSSHClient{config: &Config{
|
|
AuthMethod: "key",
|
|
PrivateKeyPath: keyPath,
|
|
}}
|
|
methods, err := c.buildAuthMethods()
|
|
if err != nil {
|
|
t.Fatalf("buildAuthMethods: %v", err)
|
|
}
|
|
if len(methods) != 1 {
|
|
t.Errorf("expected 1 auth method, got %d", len(methods))
|
|
}
|
|
}
|
|
|
|
func TestBuildAuthMethods_KeyFromPath_FileNotFound(t *testing.T) {
|
|
c := &realSSHClient{config: &Config{
|
|
AuthMethod: "key",
|
|
PrivateKeyPath: "/nonexistent/path/id_rsa",
|
|
}}
|
|
_, err := c.buildAuthMethods()
|
|
if err == nil || !strings.Contains(err.Error(), "read private key") {
|
|
t.Fatalf("expected file-not-found error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildAuthMethods_NoKeyConfigured(t *testing.T) {
|
|
c := &realSSHClient{config: &Config{
|
|
AuthMethod: "key",
|
|
// neither PrivateKey nor PrivateKeyPath set
|
|
}}
|
|
_, err := c.buildAuthMethods()
|
|
if err == nil || !strings.Contains(err.Error(), "private_key") {
|
|
t.Fatalf("expected missing-key error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildAuthMethods_KeyParseFailure(t *testing.T) {
|
|
c := &realSSHClient{config: &Config{
|
|
AuthMethod: "key",
|
|
PrivateKey: "-----BEGIN PRIVATE KEY-----\nnot-actually-a-key\n-----END PRIVATE KEY-----",
|
|
}}
|
|
_, err := c.buildAuthMethods()
|
|
if err == nil || !strings.Contains(err.Error(), "parse private key") {
|
|
t.Fatalf("expected parse error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildAuthMethods_UnsupportedMethod(t *testing.T) {
|
|
c := &realSSHClient{config: &Config{
|
|
AuthMethod: "kerberos",
|
|
}}
|
|
_, err := c.buildAuthMethods()
|
|
if err == nil || !strings.Contains(err.Error(), "unsupported auth method") {
|
|
t.Fatalf("expected unsupported-method error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// WriteFile / Execute / StatFile — not-connected guards
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestWriteFile_NotConnected(t *testing.T) {
|
|
c := &realSSHClient{config: &Config{}}
|
|
err := c.WriteFile("/tmp/test", []byte("data"), 0o644)
|
|
if err == nil || !strings.Contains(err.Error(), "SFTP client not connected") {
|
|
t.Fatalf("expected not-connected error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestExecute_NotConnected(t *testing.T) {
|
|
c := &realSSHClient{config: &Config{}}
|
|
_, err := c.Execute(t.Context(), "echo hi")
|
|
if err == nil || !strings.Contains(err.Error(), "SSH client not connected") {
|
|
t.Fatalf("expected not-connected error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestStatFile_NotConnected(t *testing.T) {
|
|
c := &realSSHClient{config: &Config{}}
|
|
_, err := c.StatFile("/tmp/test")
|
|
if err == nil || !strings.Contains(err.Error(), "SFTP client not connected") {
|
|
t.Fatalf("expected not-connected error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Close — idempotent
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestClose_NeverConnected(t *testing.T) {
|
|
c := &realSSHClient{config: &Config{}}
|
|
if err := c.Close(); err != nil {
|
|
t.Errorf("Close on nil clients should not error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestClose_Idempotent(t *testing.T) {
|
|
c := &realSSHClient{config: &Config{}}
|
|
if err := c.Close(); err != nil {
|
|
t.Errorf("first Close: %v", err)
|
|
}
|
|
if err := c.Close(); err != nil {
|
|
t.Errorf("second Close: %v", err)
|
|
}
|
|
}
|