Files
shankar0123 41a8f5853e Bundle M (Coverage Audit Closure): connector failure-mode round — 3 of 4 sub-batches
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
2026-04-27 17:24:55 +00:00

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)
}
}