// This file tests the `orca node` subcommand family (join/leave/list, // capacity is covered in node_capacity_test.go). Tests execute rootCmd // against a temp ORCA_HOME and assert stdout/stderr/exit per RESEARCH // §1.2. // // daemon.go is EXCLUDED from the cli ≥70% coverage target: the daemon // command starts a long-running mTLS server whose lifecycle is better // covered by internal/daemon/server_test.go (already 150 LOC). The // --pprof flag registration is verified in daemon_test.go. package cli import ( "bytes" "context" "encoding/json" "os" "path/filepath" "strings" "testing" "time" "git.cloudinit.dev/coreci/orca/internal/certpaths" "git.cloudinit.dev/coreci/orca/internal/model" "git.cloudinit.dev/coreci/orca/internal/security" "git.cloudinit.dev/coreci/orca/internal/store" ) func TestNodeJoinLocalText(t *testing.T) { _, cleanup := initTestEnv(t) defer cleanup() resetRootFlags(t) var buf bytes.Buffer rootCmd.SetOut(&buf) rootCmd.SetErr(&buf) rootCmd.SetArgs([]string{"node", "join", "--name", "worker-1", "--addr", "10.0.0.5:8443"}) if err := rootCmd.Execute(); err != nil { t.Fatalf("node join: %v", err) } out := buf.String() if !strings.Contains(out, "Node joined") { t.Errorf("node join output unexpected: %s", out) } if !strings.Contains(out, "worker-1") { t.Errorf("node join output missing name: %s", out) } } func TestNodeJoinLocalJSON(t *testing.T) { _, cleanup := initTestEnv(t) defer cleanup() resetRootFlags(t) var buf bytes.Buffer rootCmd.SetOut(&buf) rootCmd.SetErr(&buf) rootCmd.SetArgs([]string{"node", "join", "--name", "worker-2", "--addr", "10.0.0.6:8443", "--json"}) if err := rootCmd.Execute(); err != nil { t.Fatalf("node join --json: %v", err) } var node map[string]any if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &node); err != nil { t.Fatalf("unmarshal node json: %v\n%s", err, buf.String()) } if node["name"] != "worker-2" { t.Errorf("node join --json name = %v, want worker-2", node["name"]) } if node["address"] != "10.0.0.6:8443" { t.Errorf("node join --json address = %v, want 10.0.0.6:8443", node["address"]) } } func TestNodeJoinMissingName(t *testing.T) { _, cleanup := initTestEnv(t) defer cleanup() resetRootFlags(t) var buf bytes.Buffer rootCmd.SetOut(&buf) rootCmd.SetErr(&buf) rootCmd.SetArgs([]string{"node", "join"}) if err := rootCmd.Execute(); err == nil { t.Fatal("expected error for missing --name, got nil") } } func TestNodeJoinDefaultAddr(t *testing.T) { _, cleanup := initTestEnv(t) defer cleanup() resetRootFlags(t) var buf bytes.Buffer rootCmd.SetOut(&buf) rootCmd.SetErr(&buf) rootCmd.SetArgs([]string{"node", "join", "--name", "defaulter", "--json"}) if err := rootCmd.Execute(); err != nil { t.Fatalf("node join: %v", err) } var node map[string]any if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &node); err != nil { t.Fatalf("unmarshal node json: %v\n%s", err, buf.String()) } if node["address"] != "localhost:8443" { t.Errorf("node join default addr = %v, want localhost:8443", node["address"]) } } func TestNodeJoinCAFingerprintMatch(t *testing.T) { _, cleanup := initTestEnv(t) defer cleanup() if err := runInit(discardWriter{}); err != nil { t.Fatalf("init: %v", err) } fp, err := security.Fingerprint(certpaths.CACertPath()) if err != nil { t.Fatalf("fingerprint: %v", err) } resetRootFlags(t) var buf bytes.Buffer rootCmd.SetOut(&buf) rootCmd.SetErr(&buf) rootCmd.SetArgs([]string{"node", "join", "--name", "pinned", "--ca-fingerprint", fp, "--json"}) if err := rootCmd.Execute(); err != nil { t.Fatalf("node join with matching fingerprint: %v", err) } } func TestNodeJoinCAFingerprintMismatch(t *testing.T) { _, cleanup := initTestEnv(t) defer cleanup() resetRootFlags(t) var buf bytes.Buffer rootCmd.SetOut(&buf) rootCmd.SetErr(&buf) rootCmd.SetArgs([]string{"node", "join", "--name", "badpin", "--ca-fingerprint", padHex(64)}) if err := rootCmd.Execute(); err == nil { t.Fatal("expected error for CA fingerprint mismatch, got nil") } } func TestNodeJoinCAFingerprintNoCA(t *testing.T) { _, cleanup := initTestEnv(t) defer cleanup() resetRootFlags(t) var buf bytes.Buffer rootCmd.SetOut(&buf) rootCmd.SetErr(&buf) rootCmd.SetArgs([]string{"node", "join", "--name", "noca", "--ca-fingerprint", padHex(64)}) if err := rootCmd.Execute(); err == nil { t.Fatal("expected error for missing CA with --ca-fingerprint, got nil") } } func TestNodeJoinProxmoxMissingHost(t *testing.T) { _, cleanup := initTestEnv(t) defer cleanup() resetRootFlags(t) var buf bytes.Buffer rootCmd.SetOut(&buf) rootCmd.SetErr(&buf) rootCmd.SetArgs([]string{"node", "join", "--type", "proxmox", "--password", "x"}) if err := rootCmd.Execute(); err == nil { t.Fatal("expected error for proxmox without --host, got nil") } } func TestNodeJoinProxmoxMissingPassword(t *testing.T) { _, cleanup := initTestEnv(t) defer cleanup() resetRootFlags(t) var buf bytes.Buffer rootCmd.SetOut(&buf) rootCmd.SetErr(&buf) rootCmd.SetArgs([]string{"node", "join", "--type", "proxmox", "--host", "10.0.0.99"}) if err := rootCmd.Execute(); err == nil { t.Fatal("expected error for proxmox without password, got nil") } } func TestNodeListEmpty(t *testing.T) { _, cleanup := initTestEnv(t) defer cleanup() resetRootFlags(t) var buf bytes.Buffer rootCmd.SetOut(&buf) rootCmd.SetErr(&buf) rootCmd.SetArgs([]string{"node", "list"}) if err := rootCmd.Execute(); err != nil { t.Fatalf("node list: %v", err) } } func TestNodeListAfterJoin(t *testing.T) { _, cleanup := initTestEnv(t) defer cleanup() resetRootFlags(t) rootCmd.SetArgs([]string{"node", "join", "--name", "lister", "--addr", "10.0.0.7:8443"}) if err := rootCmd.Execute(); err != nil { t.Fatalf("node join: %v", err) } resetRootFlags(t) var buf bytes.Buffer rootCmd.SetOut(&buf) rootCmd.SetErr(&buf) rootCmd.SetArgs([]string{"node", "list"}) if err := rootCmd.Execute(); err != nil { t.Fatalf("node list: %v", err) } out := buf.String() if !strings.Contains(out, "lister") { t.Errorf("node list missing joined node: %s", out) } } func TestNodeListJSON(t *testing.T) { _, cleanup := initTestEnv(t) defer cleanup() resetRootFlags(t) rootCmd.SetArgs([]string{"node", "join", "--name", "jsonlister", "--addr", "10.0.0.8:8443"}) if err := rootCmd.Execute(); err != nil { t.Fatalf("node join: %v", err) } resetRootFlags(t) var buf bytes.Buffer rootCmd.SetOut(&buf) rootCmd.SetErr(&buf) rootCmd.SetArgs([]string{"node", "list", "--json"}) if err := rootCmd.Execute(); err != nil { t.Fatalf("node list --json: %v", err) } var nodes []map[string]any if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &nodes); err != nil { t.Fatalf("unmarshal node list json: %v\n%s", err, buf.String()) } found := false for _, n := range nodes { if n["name"] == "jsonlister" { found = true } } if !found { t.Errorf("node list --json missing jsonlister: %s", buf.String()) } } func TestNodeLeave(t *testing.T) { _, cleanup := initTestEnv(t) defer cleanup() nodeID := seedNode(t, "leaver", "10.0.0.9:8443") resetRootFlags(t) var buf bytes.Buffer rootCmd.SetOut(&buf) rootCmd.SetErr(&buf) rootCmd.SetArgs([]string{"node", "leave", nodeID}) if err := rootCmd.Execute(); err != nil { t.Fatalf("node leave: %v", err) } if !strings.Contains(buf.String(), "Node left") { t.Errorf("node leave output unexpected: %s", buf.String()) } } func TestNodeLeaveJSON(t *testing.T) { _, cleanup := initTestEnv(t) defer cleanup() nodeID := seedNode(t, "jsonleaver", "10.0.0.10:8443") resetRootFlags(t) var buf bytes.Buffer rootCmd.SetOut(&buf) rootCmd.SetErr(&buf) rootCmd.SetArgs([]string{"node", "leave", nodeID, "--json"}) if err := rootCmd.Execute(); err != nil { t.Fatalf("node leave --json: %v", err) } var result map[string]any if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &result); err != nil { t.Fatalf("unmarshal node leave json: %v\n%s", err, buf.String()) } if result["state"] != "left" { t.Errorf("node leave --json state = %v, want left", result["state"]) } } func TestNodeLeaveMissingID(t *testing.T) { _, cleanup := initTestEnv(t) defer cleanup() resetRootFlags(t) var buf bytes.Buffer rootCmd.SetOut(&buf) rootCmd.SetErr(&buf) rootCmd.SetArgs([]string{"node", "leave"}) if err := rootCmd.Execute(); err == nil { t.Fatal("expected error for node leave without id, got nil") } } func seedNode(t *testing.T, name, addr string) string { t.Helper() db, err := store.Open(certpaths.DBPath()) if err != nil { t.Fatalf("open db: %v", err) } defer db.Close() repo := store.NewNodeRepo(db) ctx := context.Background() n := &model.Node{ ID: "node-" + name, Name: name, Address: addr, State: model.NodeStateReady, JoinedAt: time.Now().UTC(), LastSeen: time.Now().UTC(), } if err := repo.Insert(ctx, n); err != nil { t.Fatalf("insert node: %v", err) } return n.ID } func padHex(n int) string { b := make([]byte, n) for i := range b { b[i] = 'a' } return string(b) } // TestNodeKeyReset removes the target node's known_hosts lines, leaves // other hosts' lines intact, and inserts an audit row (T02.8, REQ-059). func TestNodeKeyReset(t *testing.T) { _, cleanup := initTestEnv(t) defer cleanup() // Seed a proxmox node whose Name is the host address (matches the // key-reset RunE, which uses node.Name as the known_hosts match key). seedProxmoxNode(t, "10.0.0.1", "10.0.0.1:8443") // Pre-populate known_hosts: 2 lines for the target + 1 for another host. knownHosts := certpaths.KnownHostsPath() if err := os.MkdirAll(filepath.Dir(knownHosts), 0o755); err != nil { t.Fatalf("mkdir known_hosts dir: %v", err) } original := []byte("[10.0.0.1]:22 ssh-ed25519 AAAAKEY1 host1\n" + "10.0.0.1 ssh-ed25519 AAAAKEY1ALT host1-alt\n" + "[10.0.0.2]:22 ssh-ed25519 AAAAKEY2 host2\n") if err := os.WriteFile(knownHosts, original, 0o600); err != nil { t.Fatalf("write known_hosts: %v", err) } resetRootFlags(t) var buf bytes.Buffer rootCmd.SetOut(&buf) rootCmd.SetErr(&buf) rootCmd.SetArgs([]string{"node", "key-reset", "10.0.0.1"}) if err := rootCmd.Execute(); err != nil { t.Fatalf("node key-reset: %v", err) } out := buf.String() if !strings.Contains(out, "Host key reset for 10.0.0.1") { t.Errorf("output missing reset confirmation: %s", out) } // known_hosts: target's 2 lines removed, other host's line intact. data, err := os.ReadFile(knownHosts) if err != nil { t.Fatalf("read known_hosts: %v", err) } result := string(data) if strings.Contains(result, "AAAAKEY1") { t.Errorf("target key line 1 not removed: %s", result) } if strings.Contains(result, "AAAAKEY1ALT") { t.Errorf("target key line 2 not removed: %s", result) } if !strings.Contains(result, "AAAAKEY2") { t.Errorf("other host's line was removed (should be intact): %s", result) } // Audit row inserted with action=node.key_reset. db, err := store.Open(certpaths.DBPath()) if err != nil { t.Fatalf("open db: %v", err) } defer db.Close() entries, err := store.NewAuditRepo(db).List(context.Background(), 50) if err != nil { t.Fatalf("list audit: %v", err) } found := false for _, e := range entries { if e.Action == "node.key_reset" && strings.Contains(e.Resource, "10.0.0.1") { found = true break } } if !found { t.Errorf("audit row for node.key_reset not inserted: %+v", entries) } } // TestNodeKeyReset_NodeNotFound verifies key-reset errors when the // node is not in the registry (T02.8). func TestNodeKeyReset_NodeNotFound(t *testing.T) { _, cleanup := initTestEnv(t) defer cleanup() resetRootFlags(t) var buf bytes.Buffer rootCmd.SetOut(&buf) rootCmd.SetErr(&buf) rootCmd.SetArgs([]string{"node", "key-reset", "no.such.host"}) err := rootCmd.Execute() if err == nil { t.Fatal("expected error for unknown node, got nil") } if !strings.Contains(err.Error(), "not found") { t.Errorf("error should mention not found, got: %v", err) } } func seedProxmoxNode(t *testing.T, name, addr string) string { t.Helper() db, err := store.Open(certpaths.DBPath()) if err != nil { t.Fatalf("open db: %v", err) } defer db.Close() repo := store.NewNodeRepo(db) ctx := context.Background() n := &model.Node{ ID: "node-" + name, Name: name, Address: addr, State: model.NodeStateReady, JoinedAt: time.Now().UTC(), LastSeen: time.Now().UTC(), Kind: string(model.NodeKindProxmox), OS: "pve", } if err := repo.Insert(ctx, n); err != nil { t.Fatalf("insert proxmox node: %v", err) } return n.ID }