Files
orca/internal/sshpush/idempotency_test.go
T
Jon Chery e92b18197c feat(P01): SSH-push transport layer — connection pool, retry, fan-out, idempotent writes (REQ-073)
P01 — Load-bearing replacement for v0.8 mTLS transport (R-001).

Transport (internal/sshpush/transport.go, REQ-073):
- Transport struct with sync.Map connection pool (reuse *ssh.Client per peer).
- Exec with context timeout (10s default) + retry (100ms x2 cap 5s max 5
  attempts, +/-25% jitter — same backoff as v0.8 transport/retry.go).
- ReadFile, WriteFile (atomic heredoc + mv), Close.
- sshDialer + sshSession seams for testability. TOFU host-key verification
  reuses proxmox.TOFUHostKeyCallback. security.Flock for known_hosts.

Fan-out (internal/sshpush/fanout.go):
- ExecAll, WriteAll with errgroup + SetLimit semaphore (default 8 per I-B-001).
  Per-peer errors collected, don't cancel the group.

Idempotency (internal/sshpush/idempotency.go, C-18):
- WriteFileIdempotent: SHA-256 compare via ssh sha256sum; skip if content
  matches (written=false). Content-addressed idempotency replaces the v0.8
  X-Orca-Idempotency-Key header (C-18 capability map).

Tests: in-process fake SSH server (ssh.NewServerConn NoClientAuth ed25519)
for e2e + interface seams for pure-logic. 93.0% coverage. 20 packages pass.

---ci---
project: orca
phase: P01
milestone: v0.9
status: execute
---/ci---
2026-08-05 17:35:11 +00:00

349 lines
9.1 KiB
Go

package sshpush
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"os"
"strings"
"testing"
"golang.org/x/crypto/ssh"
)
func TestSha256Hex(t *testing.T) {
got := sha256Hex([]byte("hello"))
want := "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
if got != want {
t.Errorf("sha256Hex = %q, want %q", got, want)
}
}
func TestSplitDir(t *testing.T) {
dir, base := splitDir("/etc/orca/foo.conf")
if dir != "/etc/orca" || base != "/etc/orca" {
t.Errorf("splitDir(/etc/orca/foo.conf) = (%q,%q), want (/etc/orca,/etc/orca)", dir, base)
}
dir, base = splitDir("foo.conf")
if dir != "" || base != "." {
t.Errorf("splitDir(foo.conf) = (%q,%q), want (\"\",.)", dir, base)
}
}
func TestRandomToken(t *testing.T) {
a := randomToken(8)
b := randomToken(8)
if a == b {
t.Error("randomToken returned same value twice")
}
if len(a) != 16 { // 8 bytes hex = 16 chars
t.Errorf("randomToken(8) len = %d, want 16", len(a))
}
}
// --- WriteFileIdempotent tests via the fake SSH server ---
func TestWriteFileIdempotent_WritesWhenFileMissing(t *testing.T) {
srv := newFakeSSHServer(t)
defer srv.close()
tr := realTransport(t, srv)
defer tr.Close()
content := []byte("first content\n")
written, err := tr.WriteFileIdempotent(context.Background(), srv.addr(), "/etc/orca/a.conf", content, 0o644)
if err != nil {
t.Fatalf("WriteFileIdempotent: %v", err)
}
if !written {
t.Error("written=false, want true (file was missing)")
}
srv.mu.Lock()
got := srv.files["/etc/orca/a.conf"]
srv.mu.Unlock()
if got != string(content) {
t.Errorf("remote file = %q, want %q", got, string(content))
}
}
func TestWriteFileIdempotent_SkipsWhenContentMatches(t *testing.T) {
srv := newFakeSSHServer(t)
defer srv.close()
content := []byte("same content\n")
srv.mu.Lock()
srv.files["/etc/orca/b.conf"] = string(content)
srv.mu.Unlock()
tr := realTransport(t, srv)
defer tr.Close()
written, err := tr.WriteFileIdempotent(context.Background(), srv.addr(), "/etc/orca/b.conf", content, 0o644)
if err != nil {
t.Fatalf("WriteFileIdempotent: %v", err)
}
if written {
t.Error("written=true, want false (content matched)")
}
}
func TestWriteFileIdempotent_WritesWhenContentDiffers(t *testing.T) {
srv := newFakeSSHServer(t)
defer srv.close()
srv.mu.Lock()
srv.files["/etc/orca/c.conf"] = "old content\n"
srv.mu.Unlock()
tr := realTransport(t, srv)
defer tr.Close()
newContent := []byte("new content\n")
written, err := tr.WriteFileIdempotent(context.Background(), srv.addr(), "/etc/orca/c.conf", newContent, 0o644)
if err != nil {
t.Fatalf("WriteFileIdempotent: %v", err)
}
if !written {
t.Error("written=false, want true (content differed)")
}
srv.mu.Lock()
got := srv.files["/etc/orca/c.conf"]
srv.mu.Unlock()
if got != string(newContent) {
t.Errorf("remote file = %q, want %q", got, string(newContent))
}
}
func TestWriteFile_DelegatesToIdempotent(t *testing.T) {
srv := newFakeSSHServer(t)
defer srv.close()
tr := realTransport(t, srv)
defer tr.Close()
content := []byte("delegated\n")
if err := tr.WriteFile(context.Background(), srv.addr(), "/etc/orca/d.conf", content, 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
srv.mu.Lock()
got := srv.files["/etc/orca/d.conf"]
srv.mu.Unlock()
if got != string(content) {
t.Errorf("remote file = %q, want %q", got, string(content))
}
}
// --- Pure-logic idempotency tests via mock session (no SSH server) ---
// mockHashSession returns the hash of the file matching the sha256sum
// command's path argument; for write commands (mkdir + cat >), it
// records the write. This lets us test the idempotency decision logic
// without a real SSH server.
type mockHashSession struct {
files map[string]string
out []byte
err error
cmd string
writeHook func(cmd string)
}
func (m *mockHashSession) CombinedOutput(cmd string) ([]byte, error) {
m.cmd = cmd
c := strings.TrimSpace(cmd)
if strings.HasPrefix(c, "sha256sum ") {
rest := strings.TrimSpace(strings.TrimPrefix(c, "sha256sum "))
rest = strings.TrimSuffix(rest, " 2>/dev/null")
rest = strings.TrimSpace(rest)
path := unquote(rest)
content, ok := m.files[path]
if !ok {
return []byte(""), nil
}
sum := sha256.Sum256([]byte(content))
return []byte(hex.EncodeToString(sum[:]) + " " + path + "\n"), nil
}
if strings.HasPrefix(c, "mkdir -p ") && strings.Contains(c, "cat >") {
if m.writeHook != nil {
m.writeHook(c)
}
return nil, nil
}
return m.out, m.err
}
func (m *mockHashSession) Close() error { return nil }
func TestWriteFileIdempotent_MockSkip(t *testing.T) {
srv := newFakeSSHServer(t)
defer srv.close()
tr := realTransport(t, srv)
defer tr.Close()
files := map[string]string{"/x/f": "match"}
var writes int
tr.SetSessionFactory(func(c *ssh.Client) (sshSession, error) {
return &mockHashSession{
files: files,
writeHook: func(string) { writes++ },
}, nil
})
client, err := tr.dial(srv.addr())
if err != nil {
t.Fatalf("dial: %v", err)
}
tr.pool.Store(srv.addr(), client)
written, err := tr.WriteFileIdempotent(context.Background(), srv.addr(), "/x/f", []byte("match"), 0o644)
if err != nil {
t.Fatalf("WriteFileIdempotent: %v", err)
}
if written {
t.Error("written=true, want false (hash matched)")
}
if writes != 0 {
t.Errorf("writes = %d, want 0 (no write on hash match)", writes)
}
}
func TestWriteFileIdempotent_MockWrite(t *testing.T) {
srv := newFakeSSHServer(t)
defer srv.close()
tr := realTransport(t, srv)
defer tr.Close()
files := map[string]string{"/x/f": "old"}
var writes int
tr.SetSessionFactory(func(c *ssh.Client) (sshSession, error) {
return &mockHashSession{
files: files,
writeHook: func(string) { writes++ },
}, nil
})
client, err := tr.dial(srv.addr())
if err != nil {
t.Fatalf("dial: %v", err)
}
tr.pool.Store(srv.addr(), client)
written, err := tr.WriteFileIdempotent(context.Background(), srv.addr(), "/x/f", []byte("new"), 0o644)
if err != nil {
t.Fatalf("WriteFileIdempotent: %v", err)
}
if !written {
t.Error("written=false, want true (hash differed)")
}
if writes != 1 {
t.Errorf("writes = %d, want 1", writes)
}
}
func TestWriteFileIdempotent_MockMissingFile(t *testing.T) {
srv := newFakeSSHServer(t)
defer srv.close()
tr := realTransport(t, srv)
defer tr.Close()
files := map[string]string{}
var writes int
tr.SetSessionFactory(func(c *ssh.Client) (sshSession, error) {
return &mockHashSession{
files: files,
writeHook: func(string) { writes++ },
}, nil
})
client, err := tr.dial(srv.addr())
if err != nil {
t.Fatalf("dial: %v", err)
}
tr.pool.Store(srv.addr(), client)
written, err := tr.WriteFileIdempotent(context.Background(), srv.addr(), "/x/new", []byte("fresh"), 0o644)
if err != nil {
t.Fatalf("WriteFileIdempotent: %v", err)
}
if !written {
t.Error("written=false, want true (file missing)")
}
if writes != 1 {
t.Errorf("writes = %d, want 1", writes)
}
}
func TestRemoteSHA256_ParsesDigest(t *testing.T) {
srv := newFakeSSHServer(t)
defer srv.close()
srv.mu.Lock()
srv.files["/x/h"] = "abc"
srv.mu.Unlock()
tr := realTransport(t, srv)
defer tr.Close()
got, err := tr.remoteSHA256(context.Background(), srv.addr(), "/x/h")
if err != nil {
t.Fatalf("remoteSHA256: %v", err)
}
want := fmt.Sprintf("%x", sha256.Sum256([]byte("abc")))
if got != want {
t.Errorf("remoteSHA256 = %q, want %q", got, want)
}
}
func TestRemoteSHA256_MissingFile(t *testing.T) {
srv := newFakeSSHServer(t)
defer srv.close()
tr := realTransport(t, srv)
defer tr.Close()
got, err := t_remoteSHA256_noErr(t, tr, srv.addr(), "/missing")
if err != nil {
t.Fatalf("remoteSHA256: %v", err)
}
if got != "" {
t.Errorf("remoteSHA256 = %q, want empty for missing file", got)
}
}
func t_remoteSHA256_noErr(t *testing.T, tr *Transport, peer, path string) (string, error) {
t.Helper()
return tr.remoteSHA256(context.Background(), peer, path)
}
func TestWriteFile_ModeApplied(t *testing.T) {
srv := newFakeSSHServer(t)
defer srv.close()
tr := realTransport(t, srv)
defer tr.Close()
if err := tr.WriteFile(context.Background(), srv.addr(), "/m/f", []byte("mode"), 0o755); err != nil {
t.Fatalf("WriteFile: %v", err)
}
srv.mu.Lock()
got := srv.files["/m/f"]
srv.mu.Unlock()
if got != "mode" {
t.Errorf("content = %q, want mode", got)
}
}
func TestWriteFileIdempotent_ExecError(t *testing.T) {
srv := newFakeSSHServer(t)
defer srv.close()
tr := realTransport(t, srv)
defer tr.Close()
tr.SetSessionFactory(func(c *ssh.Client) (sshSession, error) {
return &mockSession{err: fmt.Errorf("%w: boom", ErrPermanent)}, nil
})
client, err := tr.dial(srv.addr())
if err != nil {
t.Fatalf("dial: %v", err)
}
tr.pool.Store(srv.addr(), client)
_, err = tr.WriteFileIdempotent(context.Background(), srv.addr(), "/x/f", []byte("z"), 0o644)
if err == nil {
t.Fatal("expected error, got nil")
}
}
func TestParseMode(t *testing.T) {
for _, tc := range []struct {
in string
want os.FileMode
}{
{"0644", 0o644},
{"0755", 0o755},
{"0600", 0o600},
{"bad", 0o644},
{"", 0o644},
{"0", 0o644},
} {
got := parseMode(tc.in)
if got != tc.want {
t.Errorf("parseMode(%q) = %o, want %o", tc.in, got, tc.want)
}
}
}
var _ = errors.New