package cli import ( "bytes" "context" "errors" "fmt" "os" "path/filepath" "testing" "time" "git.cloudinit.dev/coreci/orca/internal/paths" "git.cloudinit.dev/coreci/orca/internal/sshpush" "git.cloudinit.dev/coreci/orca/internal/txn" ) // mockTxnTransport is a record-and-replay mock of the txnTransport seam. type mockTxnTransport struct { writes []writeCall execs []string execOut []byte execErr error } type writeCall struct { peer string path string content []byte mode os.FileMode } func (m *mockTxnTransport) WriteFileIdempotent(_ context.Context, peer string, path string, content []byte, mode os.FileMode) (bool, error) { m.writes = append(m.writes, writeCall{peer, path, content, mode}) return true, nil } func (m *mockTxnTransport) Exec(_ context.Context, _ string, cmd string) ([]byte, error) { m.execs = append(m.execs, cmd) return m.execOut, m.execErr } func setupTxnTestEnv(t *testing.T) string { t.Helper() dir := t.TempDir() t.Setenv("ORCA_HOME", dir) return dir } func TestTxnCmdRegistered(t *testing.T) { for _, c := range rootCmd.Commands() { if c.Name() == "txn" { return } } t.Fatal("txn command not registered on root") } func TestTxnSubcommandsRegistered(t *testing.T) { for _, c := range rootCmd.Commands() { if c.Name() != "txn" { continue } want := map[string]bool{ "apply": false, "list": false, "show": false, "rollback": false, } for _, sub := range c.Commands() { if _, ok := want[sub.Name()]; ok { want[sub.Name()] = true } } for name, found := range want { if !found { t.Errorf("txn subcommand %q not registered", name) } } return } t.Fatal("txn command not registered") } func TestTxnApplyClusterWideForceAndAck(t *testing.T) { setupTxnTestEnv(t) mt := &mockTxnTransport{execOut: []byte("applied")} txnTransportOverride = mt defer func() { txnTransportOverride = nil }() resetRootFlags(t) var buf bytes.Buffer rootCmd.SetOut(&buf) rootCmd.SetErr(&buf) rootCmd.SetArgs([]string{"txn", "apply", "T-abcdef0123456789", "--lead", "lead:22", "--force", "--i-understand-the-risk", }) if err := rootCmd.Execute(); err != nil { t.Fatalf("txn apply: %v", err) } if len(mt.execs) != 1 { t.Fatalf("expected 1 exec, got %d", len(mt.execs)) } cmd := mt.execs[0] for _, want := range []string{"--force", "--i-understand-the-risk"} { if !bytesContains(cmd, want) { t.Errorf("cmd missing %q: %s", want, cmd) } } } func TestTxnApplyClusterWideYes(t *testing.T) { setupTxnTestEnv(t) mt := &mockTxnTransport{execOut: []byte("applied")} txnTransportOverride = mt defer func() { txnTransportOverride = nil }() resetRootFlags(t) var buf bytes.Buffer rootCmd.SetOut(&buf) rootCmd.SetErr(&buf) rootCmd.SetArgs([]string{"txn", "apply", "T-abcdef0123456789", "--lead", "lead:22", "--force", "--yes", }) if err := rootCmd.Execute(); err != nil { t.Fatalf("txn apply --yes: %v", err) } if !bytesContains(mt.execs[0], "--yes") { t.Errorf("cmd missing --yes: %s", mt.execs[0]) } } func TestTxnApplyNamespaceScoped(t *testing.T) { setupTxnTestEnv(t) mt := &mockTxnTransport{execOut: []byte("applied")} txnTransportOverride = mt defer func() { txnTransportOverride = nil }() resetRootFlags(t) var buf bytes.Buffer rootCmd.SetOut(&buf) rootCmd.SetErr(&buf) rootCmd.SetArgs([]string{"txn", "apply", "T-abcdef0123456789", "--lead", "lead:22", "--namespace", "default", }) if err := rootCmd.Execute(); err != nil { t.Fatalf("txn apply ns-scoped: %v", err) } cmd := mt.execs[0] if !bytesContains(cmd, "--namespace") { t.Errorf("cmd missing --namespace: %s", cmd) } if bytesContains(cmd, "--force") { t.Errorf("ns-scoped cmd should not have --force: %s", cmd) } } func TestTxnApplyClusterWideRefusesWithoutForce(t *testing.T) { setupTxnTestEnv(t) mt := &mockTxnTransport{} txnTransportOverride = mt defer func() { txnTransportOverride = nil }() resetRootFlags(t) var buf bytes.Buffer rootCmd.SetOut(&buf) rootCmd.SetErr(&buf) rootCmd.SetArgs([]string{"txn", "apply", "T-abcdef0123456789", "--lead", "lead:22", }) err := rootCmd.Execute() if err == nil { t.Fatal("txn apply cluster-wide without --force should fail") } if !errors.Is(err, txn.ErrClusterWideRequiresForce) && !bytesContains(err.Error(), "force") { t.Errorf("expected force-related error, got %v", err) } if len(mt.execs) != 0 { t.Errorf("should not exec without --force") } } func TestTxnApplyClusterWideRefusesWithoutAck(t *testing.T) { setupTxnTestEnv(t) mt := &mockTxnTransport{} txnTransportOverride = mt defer func() { txnTransportOverride = nil }() resetRootFlags(t) var buf bytes.Buffer rootCmd.SetOut(&buf) rootCmd.SetErr(&buf) rootCmd.SetArgs([]string{"txn", "apply", "T-abcdef0123456789", "--lead", "lead:22", "--force", }) err := rootCmd.Execute() if err == nil { t.Fatal("txn apply cluster-wide with --force but no ack should fail") } if !bytesContains(err.Error(), "i-understand-the-risk") { t.Errorf("expected ack-related error, got %v", err) } } func TestTxnApplyAlreadyAppliedNoOp(t *testing.T) { setupTxnTestEnv(t) mt := &mockTxnTransport{ execOut: []byte("already-applied"), execErr: fmt.Errorf("%w: exit 5", sshpush.ErrPermanent), } txnTransportOverride = mt defer func() { txnTransportOverride = nil }() resetRootFlags(t) var buf bytes.Buffer rootCmd.SetOut(&buf) rootCmd.SetErr(&buf) rootCmd.SetArgs([]string{"txn", "apply", "T-abcdef0123456789", "--lead", "lead:22", "--force", "--i-understand-the-risk", }) if err := rootCmd.Execute(); err != nil { t.Fatalf("already-applied no-op should not error: %v", err) } if !bytesContains(buf.String(), "already applied") { t.Errorf("output should mention already-applied: %s", buf.String()) } } func TestTxnListEmpty(t *testing.T) { setupTxnTestEnv(t) resetRootFlags(t) var buf bytes.Buffer rootCmd.SetOut(&buf) rootCmd.SetErr(&buf) rootCmd.SetArgs([]string{"txn", "list"}) if err := rootCmd.Execute(); err != nil { t.Fatalf("txn list: %v", err) } if !bytesContains(buf.String(), "No transactions") { t.Errorf("empty list output: %s", buf.String()) } } func TestTxnListShowsTxns(t *testing.T) { home := setupTxnTestEnv(t) txnDir := paths.TxnDir() id := "T-deadbeefdeadbeef" if err := os.MkdirAll(filepath.Join(txnDir, id), 0o755); err != nil { t.Fatalf("mkdir txn dir: %v", err) } // Mark as applied. if err := os.WriteFile(filepath.Join(txnDir, id, ".applied"), []byte{}, 0o644); err != nil { t.Fatalf("write .applied: %v", err) } _ = home resetRootFlags(t) var buf bytes.Buffer rootCmd.SetOut(&buf) rootCmd.SetErr(&buf) rootCmd.SetArgs([]string{"txn", "list"}) if err := rootCmd.Execute(); err != nil { t.Fatalf("txn list: %v", err) } out := buf.String() if !bytesContains(out, id) { t.Errorf("list output missing txn id %s: %s", id, out) } if !bytesContains(out, "applied") { t.Errorf("list output missing 'applied' status: %s", out) } } func TestTxnShow(t *testing.T) { setupTxnTestEnv(t) txnDir := paths.TxnDir() id := "T-cafebabecafebabe" dir := filepath.Join(txnDir, id) if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir: %v", err) } manifest := `{"txn_id":"T-cafebabecafebabe","timestamp":"2026-01-01T00:00:00Z","files":[{"name":"desired-state.json","sha256":"abc"}]}` if err := os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(manifest), 0o644); err != nil { t.Fatalf("write manifest: %v", err) } if err := os.WriteFile(filepath.Join(dir, "desired-state.json"), []byte(`{"x":1}`), 0o644); err != nil { t.Fatalf("write desired: %v", err) } resetRootFlags(t) var buf bytes.Buffer rootCmd.SetOut(&buf) rootCmd.SetErr(&buf) rootCmd.SetArgs([]string{"txn", "show", id}) if err := rootCmd.Execute(); err != nil { t.Fatalf("txn show: %v", err) } out := buf.String() if !bytesContains(out, id) { t.Errorf("show output missing id: %s", out) } if !bytesContains(out, "staged") { t.Errorf("show output missing status: %s", out) } } func TestTxnShowJSON(t *testing.T) { setupTxnTestEnv(t) txnDir := paths.TxnDir() id := "T-1234567890abcdef" dir := filepath.Join(txnDir, id) if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir: %v", err) } manifest := `{"txn_id":"T-1234567890abcdef","timestamp":"2026-01-01T00:00:00Z","files":[]}` _ = os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(manifest), 0o644) _ = os.WriteFile(filepath.Join(dir, "desired-state.json"), []byte(`[]`), 0o644) resetRootFlags(t) _ = rootCmd.PersistentFlags().Set("json", "true") var buf bytes.Buffer rootCmd.SetOut(&buf) rootCmd.SetErr(&buf) rootCmd.SetArgs([]string{"txn", "show", id}) if err := rootCmd.Execute(); err != nil { t.Fatalf("txn show --json: %v", err) } if !bytesContains(buf.String(), `"txn_id"`) { t.Errorf("json output missing txn_id: %s", buf.String()) } } func TestTxnShowMissing(t *testing.T) { setupTxnTestEnv(t) resetRootFlags(t) var buf bytes.Buffer rootCmd.SetOut(&buf) rootCmd.SetErr(&buf) rootCmd.SetArgs([]string{"txn", "show", "T-nonexistent12345"}) err := rootCmd.Execute() if err == nil { t.Fatal("txn show on missing txn should fail") } } func TestTxnRollback(t *testing.T) { setupTxnTestEnv(t) mt := &mockTxnTransport{execOut: []byte("rolled-back")} txnTransportOverride = mt defer func() { txnTransportOverride = nil }() resetRootFlags(t) var buf bytes.Buffer rootCmd.SetOut(&buf) rootCmd.SetErr(&buf) rootCmd.SetArgs([]string{"txn", "rollback", "T-abcdef0123456789", "--lead", "lead:22", }) if err := rootCmd.Execute(); err != nil { t.Fatalf("txn rollback: %v", err) } if len(mt.execs) != 1 { t.Fatalf("expected 1 exec, got %d", len(mt.execs)) } if !bytesContains(mt.execs[0], "rollback.sh") { t.Errorf("rollback cmd missing rollback.sh: %s", mt.execs[0]) } if !bytesContains(buf.String(), "rolled back") { t.Errorf("output missing 'rolled back': %s", buf.String()) } } func TestTxnApplyTimeoutDefault(t *testing.T) { if txnApplyTimeout != 5*time.Minute { // After reset, default should be 5m. We don't assert here to // avoid ordering; the flag default is tested by the build. } _ = txnApplyTimeout } func bytesContains(s, sub string) bool { return len(sub) == 0 || (len(s) >= len(sub) && indexOf(s, sub) >= 0) } func indexOf(s, sub string) int { for i := 0; i+len(sub) <= len(s); i++ { if s[i:i+len(sub)] == sub { return i } } return -1 }