diff --git a/internal/cli/audit_test.go b/internal/cli/audit_test.go new file mode 100644 index 0000000..e572ddf --- /dev/null +++ b/internal/cli/audit_test.go @@ -0,0 +1,113 @@ +package cli + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "git.cloudinit.dev/coreci/orca/internal/certpaths" + "git.cloudinit.dev/coreci/orca/internal/store" +) + +func TestAuditListEmpty(t *testing.T) { + _, cleanup := initTestEnv(t) + defer cleanup() + resetRootFlags(t) + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"audit", "list"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("audit list: %v", err) + } + if !strings.Contains(buf.String(), "No audit entries") { + t.Errorf("audit list empty output unexpected: %s", buf.String()) + } +} + +func TestAuditListJSONEmpty(t *testing.T) { + _, cleanup := initTestEnv(t) + defer cleanup() + resetRootFlags(t) + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"audit", "list", "--json"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("audit list --json: %v", err) + } + var entries []map[string]any + if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &entries); err != nil { + t.Fatalf("unmarshal audit json: %v\n%s", err, buf.String()) + } + if len(entries) != 0 { + t.Errorf("audit list --json empty = %d entries, want 0", len(entries)) + } +} + +func TestAuditListWithEntries(t *testing.T) { + _, cleanup := initTestEnv(t) + defer cleanup() + db, err := store.Open(certpaths.DBPath()) + if err != nil { + t.Fatalf("open db: %v", err) + } + defer db.Close() + repo := store.NewAuditRepo(db) + ctx := t.Context() + if err := repo.Append(ctx, &store.AuditEntry{ + Actor: "test", Action: "test.action", Resource: "res", Result: "success", + }); err != nil { + t.Fatalf("append audit: %v", err) + } + resetRootFlags(t) + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"audit", "list"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("audit list: %v", err) + } + out := buf.String() + if !strings.Contains(out, "test.action") { + t.Errorf("audit list missing entry: %s", out) + } + if !strings.Contains(out, "TIMESTAMP") { + t.Errorf("audit list missing header: %s", out) + } +} + +func TestAuditListLimitFlag(t *testing.T) { + _, cleanup := initTestEnv(t) + defer cleanup() + db, err := store.Open(certpaths.DBPath()) + if err != nil { + t.Fatalf("open db: %v", err) + } + defer db.Close() + repo := store.NewAuditRepo(db) + ctx := t.Context() + for i := 0; i < 5; i++ { + if err := repo.Append(ctx, &store.AuditEntry{ + Actor: "test", Action: "test.action", Resource: "res", Result: "success", + }); err != nil { + t.Fatalf("append audit %d: %v", i, err) + } + } + resetRootFlags(t) + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"audit", "list", "--json", "--limit", "2"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("audit list --json --limit 2: %v", err) + } + var entries []map[string]any + if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &entries); err != nil { + t.Fatalf("unmarshal audit json: %v\n%s", err, buf.String()) + } + if len(entries) != 2 { + t.Errorf("audit list --limit 2 = %d entries, want 2", len(entries)) + } +} diff --git a/internal/cli/doctor_test.go b/internal/cli/doctor_test.go new file mode 100644 index 0000000..344e1d7 --- /dev/null +++ b/internal/cli/doctor_test.go @@ -0,0 +1,196 @@ +package cli + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +func TestDoctorText(t *testing.T) { + _, cleanup := initTestEnv(t) + defer cleanup() + if err := runInit(discardWriter{}); err != nil { + t.Fatalf("init: %v", err) + } + resetRootFlags(t) + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"doctor"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("doctor: %v", err) + } + out := buf.String() + for _, want := range []string{"CA", "cert", "PASS", "WARN", "FAIL"} { + _ = want + } + if !strings.Contains(out, "CA") { + t.Errorf("doctor output missing CA check: %s", out) + } +} + +func TestDoctorJSON(t *testing.T) { + _, cleanup := initTestEnv(t) + defer cleanup() + if err := runInit(discardWriter{}); err != nil { + t.Fatalf("init: %v", err) + } + resetRootFlags(t) + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"doctor", "--json"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("doctor --json: %v", err) + } + var checks []map[string]any + if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &checks); err != nil { + t.Fatalf("unmarshal doctor json: %v\n%s", err, buf.String()) + } + if len(checks) == 0 { + t.Errorf("doctor --json returned no checks: %s", buf.String()) + } +} + +func TestDoctorCertSubcommand(t *testing.T) { + _, cleanup := initTestEnv(t) + defer cleanup() + if err := runInit(discardWriter{}); err != nil { + t.Fatalf("init: %v", err) + } + resetRootFlags(t) + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"doctor", "cert"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("doctor cert: %v", err) + } + out := buf.String() + if !strings.Contains(out, "CA") { + t.Errorf("doctor cert output missing CA: %s", out) + } +} + +func TestDoctorCertJSON(t *testing.T) { + _, cleanup := initTestEnv(t) + defer cleanup() + if err := runInit(discardWriter{}); err != nil { + t.Fatalf("init: %v", err) + } + resetRootFlags(t) + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"doctor", "cert", "--json"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("doctor cert --json: %v", err) + } + var results []map[string]any + if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &results); err != nil { + t.Fatalf("unmarshal doctor cert json: %v\n%s", err, buf.String()) + } + if len(results) == 0 { + t.Errorf("doctor cert --json returned no results: %s", buf.String()) + } +} + +func TestDoctorDBSubcommand(t *testing.T) { + _, cleanup := initTestEnv(t) + defer cleanup() + if err := runInit(discardWriter{}); err != nil { + t.Fatalf("init: %v", err) + } + resetRootFlags(t) + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"doctor", "db"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("doctor db: %v", err) + } + out := buf.String() + if !strings.Contains(out, "db") { + t.Errorf("doctor db output unexpected: %s", out) + } +} + +func TestDoctorOSSubcommand(t *testing.T) { + _, cleanup := initTestEnv(t) + defer cleanup() + if err := runInit(discardWriter{}); err != nil { + t.Fatalf("init: %v", err) + } + resetRootFlags(t) + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"doctor", "os"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("doctor os: %v", err) + } +} + +func TestDoctorOSJSON(t *testing.T) { + _, cleanup := initTestEnv(t) + defer cleanup() + if err := runInit(discardWriter{}); err != nil { + t.Fatalf("init: %v", err) + } + resetRootFlags(t) + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"doctor", "os", "--json"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("doctor os --json: %v", err) + } + var result map[string]any + if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &result); err != nil { + t.Fatalf("unmarshal doctor os json: %v\n%s", err, buf.String()) + } + if result["Name"] == nil { + t.Errorf("doctor os --json missing Name: %v", result) + } +} + +func TestDoctorNetworkSubcommand(t *testing.T) { + resetRootFlags(t) + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"doctor", "network"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("doctor network: %v", err) + } +} + +func TestDoctorProxmoxSubcommand(t *testing.T) { + resetRootFlags(t) + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"doctor", "proxmox"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("doctor proxmox: %v", err) + } +} + +func TestDoctorProxmoxJSON(t *testing.T) { + resetRootFlags(t) + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"doctor", "proxmox", "--json"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("doctor proxmox --json: %v", err) + } + var result map[string]any + if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &result); err != nil { + t.Fatalf("unmarshal doctor proxmox json: %v\n%s", err, buf.String()) + } + if result["Name"] == nil { + t.Errorf("doctor proxmox --json missing Name: %v", result) + } +} diff --git a/internal/cli/job_test.go b/internal/cli/job_test.go new file mode 100644 index 0000000..4f4f159 --- /dev/null +++ b/internal/cli/job_test.go @@ -0,0 +1,312 @@ +package cli + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "git.cloudinit.dev/coreci/orca/internal/certpaths" + "git.cloudinit.dev/coreci/orca/internal/model" + "git.cloudinit.dev/coreci/orca/internal/store" +) + +func writeJobSpec(t *testing.T, content string) string { + t.Helper() + dir := t.TempDir() + p := filepath.Join(dir, "spec.hcl") + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatalf("write spec: %v", err) + } + return p +} + +const trueJobSpec = `job "true" {} +task "t" { + command = "/bin/true" +} +` + +const falseJobSpec = `job "false" {} +task "t" { + command = "/bin/false" +} +` + +func TestJobRunComplete(t *testing.T) { + _, cleanup := initTestEnv(t) + defer cleanup() + spec := writeJobSpec(t, trueJobSpec) + resetRootFlags(t) + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"job", "run", spec}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("job run: %v", err) + } + if !strings.Contains(buf.String(), "Job complete") { + t.Errorf("job run output unexpected: %s", buf.String()) + } +} + +func TestJobRunCompleteJSON(t *testing.T) { + _, cleanup := initTestEnv(t) + defer cleanup() + spec := writeJobSpec(t, trueJobSpec) + resetRootFlags(t) + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"job", "run", spec, "--json"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("job run --json: %v", err) + } + var result map[string]any + if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &result); err != nil { + t.Fatalf("unmarshal job run json: %v\n%s", err, buf.String()) + } + if result["status"] != "complete" { + t.Errorf("job run --json status = %v, want complete", result["status"]) + } +} + +func TestJobRunFailed(t *testing.T) { + _, cleanup := initTestEnv(t) + defer cleanup() + spec := writeJobSpec(t, falseJobSpec) + resetRootFlags(t) + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"job", "run", spec}) + if err := rootCmd.Execute(); err == nil { + t.Fatal("expected error for failing job, got nil") + } +} + +func TestJobRunFailedJSON(t *testing.T) { + _, cleanup := initTestEnv(t) + defer cleanup() + spec := writeJobSpec(t, falseJobSpec) + resetRootFlags(t) + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"job", "run", spec, "--json"}) + if err := rootCmd.Execute(); err == nil { + t.Fatal("expected error for failing job --json, got nil") + } + if !strings.Contains(buf.String(), "failed") { + t.Errorf("job run --json failed output unexpected: %s", buf.String()) + } +} + +func TestJobRunMissingSpecFile(t *testing.T) { + _, cleanup := initTestEnv(t) + defer cleanup() + resetRootFlags(t) + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"job", "run", "/nonexistent/spec.hcl"}) + if err := rootCmd.Execute(); err == nil { + t.Fatal("expected error for missing spec file, got nil") + } +} + +func TestJobListEmpty(t *testing.T) { + _, cleanup := initTestEnv(t) + defer cleanup() + resetRootFlags(t) + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"job", "list"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("job list: %v", err) + } + if !strings.Contains(buf.String(), "No jobs") { + t.Errorf("job list empty output unexpected: %s", buf.String()) + } +} + +func TestJobListJSONEmpty(t *testing.T) { + _, cleanup := initTestEnv(t) + defer cleanup() + resetRootFlags(t) + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"job", "list", "--json"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("job list --json: %v", err) + } + var jobs []map[string]any + if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &jobs); err != nil { + t.Fatalf("unmarshal job list json: %v\n%s", err, buf.String()) + } + if len(jobs) != 0 { + t.Errorf("job list --json empty = %d jobs, want 0", len(jobs)) + } +} + +func TestJobListAfterRun(t *testing.T) { + _, cleanup := initTestEnv(t) + defer cleanup() + spec := writeJobSpec(t, trueJobSpec) + resetRootFlags(t) + rootCmd.SetArgs([]string{"job", "run", spec}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("job run: %v", err) + } + resetRootFlags(t) + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"job", "list"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("job list: %v", err) + } + out := buf.String() + if !strings.Contains(out, "true") { + t.Errorf("job list missing job name: %s", out) + } +} + +func TestJobStop(t *testing.T) { + _, cleanup := initTestEnv(t) + defer cleanup() + jobID := seedJob(t, "stopper", model.JobStatusRunning) + resetRootFlags(t) + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"job", "stop", jobID}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("job stop: %v", err) + } + if !strings.Contains(buf.String(), "Job stopped") { + t.Errorf("job stop output unexpected: %s", buf.String()) + } +} + +func TestJobStopJSON(t *testing.T) { + _, cleanup := initTestEnv(t) + defer cleanup() + jobID := seedJob(t, "jsonstopper", model.JobStatusRunning) + resetRootFlags(t) + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"job", "stop", jobID, "--json"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("job stop --json: %v", err) + } + var result map[string]any + if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &result); err != nil { + t.Fatalf("unmarshal job stop json: %v\n%s", err, buf.String()) + } + if result["status"] != "stopped" { + t.Errorf("job stop --json status = %v, want stopped", result["status"]) + } +} + +func TestJobStopNotFound(t *testing.T) { + _, cleanup := initTestEnv(t) + defer cleanup() + resetRootFlags(t) + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"job", "stop", "nonexistent-id"}) + if err := rootCmd.Execute(); err == nil { + t.Fatal("expected error for job stop not found, got nil") + } +} + +func TestJobStopMissingID(t *testing.T) { + _, cleanup := initTestEnv(t) + defer cleanup() + resetRootFlags(t) + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"job", "stop"}) + if err := rootCmd.Execute(); err == nil { + t.Fatal("expected error for job stop without id, got nil") + } +} + +func TestJobLogsEmpty(t *testing.T) { + _, cleanup := initTestEnv(t) + defer cleanup() + jobID := seedJob(t, "logger", model.JobStatusComplete) + resetRootFlags(t) + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"job", "logs", jobID}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("job logs: %v", err) + } + if !strings.Contains(buf.String(), "No tasks") { + t.Errorf("job logs empty output unexpected: %s", buf.String()) + } +} + +func TestJobLogsJSONEmpty(t *testing.T) { + _, cleanup := initTestEnv(t) + defer cleanup() + jobID := seedJob(t, "jsonlogger", model.JobStatusComplete) + resetRootFlags(t) + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"job", "logs", jobID, "--json"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("job logs --json: %v", err) + } + var tasks []map[string]any + if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &tasks); err != nil { + t.Fatalf("unmarshal job logs json: %v\n%s", err, buf.String()) + } + if len(tasks) != 0 { + t.Errorf("job logs --json empty = %d tasks, want 0", len(tasks)) + } +} + +func TestJobLogsMissingID(t *testing.T) { + _, cleanup := initTestEnv(t) + defer cleanup() + resetRootFlags(t) + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"job", "logs"}) + if err := rootCmd.Execute(); err == nil { + t.Fatal("expected error for job logs without id, got nil") + } +} + +func seedJob(t *testing.T, name string, status model.JobStatus) string { + t.Helper() + db, err := store.Open(certpaths.DBPath()) + if err != nil { + t.Fatalf("open db: %v", err) + } + defer db.Close() + repo := store.NewJobRepo(db) + j := &model.Job{ + ID: "job-" + name, + Name: name, + Spec: "spec.hcl", + Status: status, + } + if err := repo.Insert(t.Context(), j); err != nil { + t.Fatalf("insert job: %v", err) + } + return j.ID +} diff --git a/internal/cli/namespace_test.go b/internal/cli/namespace_test.go index 9d0dc84..01be688 100644 --- a/internal/cli/namespace_test.go +++ b/internal/cli/namespace_test.go @@ -18,6 +18,21 @@ func resetRootFlags(t *testing.T) { rootCmd.SetErr(&buf) _ = rootCmd.PersistentFlags().Set("system", "false") _ = rootCmd.PersistentFlags().Set("json", "false") + resetCommandFlags() +} + +// resetCommandFlags zeroes the package-level flag-bound vars used by +// individual subcommands so tests don't leak state between runs (cobra +// parses into these globals; without a reset a prior test's value +// persists). resetRootFlags calls this; tests that exercise a single +// command without resetRootFlags may call it directly. +func resetCommandFlags() { + joinName, joinAddr, joinCAFinger, joinType = "", "", "", "localhost" + joinHost, joinSSHUser, joinPassword, proxmoxUser, proxmoxRole = "", "root", "", "orca", "OrcaOperator" + joinSSHPort, leaveID, nodeWatch = 22, "", false + stopID, runTarget, runIDKey, jobWatch = "", "", "", false + capSetCPU, capSetMem, capSetDisk, capNodeID = 0, 0, 0, "" + auditLimit = 50 } func TestNamespaceDefaultsToUserHome(t *testing.T) { diff --git a/internal/cli/node_capacity_test.go b/internal/cli/node_capacity_test.go new file mode 100644 index 0000000..ff29262 --- /dev/null +++ b/internal/cli/node_capacity_test.go @@ -0,0 +1,194 @@ +package cli + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "git.cloudinit.dev/coreci/orca/internal/certpaths" + "git.cloudinit.dev/coreci/orca/internal/store" +) + +func TestNodeCapacitySetMissingArgs(t *testing.T) { + _, cleanup := initTestEnv(t) + defer cleanup() + resetRootFlags(t) + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"node", "capacity", "set", "--cpu", "1000"}) + if err := rootCmd.Execute(); err == nil { + t.Fatal("expected error for capacity set missing memory/disk, got nil") + } +} + +func TestNodeCapacitySet(t *testing.T) { + _, cleanup := initTestEnv(t) + defer cleanup() + resetRootFlags(t) + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"node", "capacity", "set", "--cpu", "2000", "--memory", "4096", "--disk", "51200"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("capacity set: %v", err) + } + if !strings.Contains(buf.String(), "Capacity set") { + t.Errorf("capacity set output unexpected: %s", buf.String()) + } +} + +func TestNodeCapacitySetJSON(t *testing.T) { + _, cleanup := initTestEnv(t) + defer cleanup() + resetRootFlags(t) + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"node", "capacity", "set", "--cpu", "3000", "--memory", "8192", "--disk", "102400", "--json"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("capacity set --json: %v", err) + } + var c map[string]any + if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &c); err != nil { + t.Fatalf("unmarshal capacity set json: %v\n%s", err, buf.String()) + } + if c["NodeID"] != "self" { + t.Errorf("capacity set --json NodeID = %v, want self", c["NodeID"]) + } +} + +func TestNodeCapacityShowNotFound(t *testing.T) { + _, cleanup := initTestEnv(t) + defer cleanup() + resetRootFlags(t) + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"node", "capacity", "show", "missing-node"}) + if err := rootCmd.Execute(); err == nil { + t.Fatal("expected error for capacity show missing node, got nil") + } +} + +func TestNodeCapacityShowAfterSet(t *testing.T) { + _, cleanup := initTestEnv(t) + defer cleanup() + seedCapacity(t, "show-node", 4000, 4096, 51200) + resetRootFlags(t) + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"node", "capacity", "show", "show-node"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("capacity show: %v", err) + } + out := buf.String() + if !strings.Contains(out, "show-node") { + t.Errorf("capacity show missing node id: %s", out) + } + if !strings.Contains(out, "4000") { + t.Errorf("capacity show missing cpu: %s", out) + } +} + +func TestNodeCapacityShowJSON(t *testing.T) { + _, cleanup := initTestEnv(t) + defer cleanup() + seedCapacity(t, "jsonshow-node", 4000, 4096, 51200) + resetRootFlags(t) + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"node", "capacity", "show", "jsonshow-node", "--json"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("capacity show --json: %v", err) + } + var c map[string]any + if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &c); err != nil { + t.Fatalf("unmarshal capacity show json: %v\n%s", err, buf.String()) + } + if c["NodeID"] != "jsonshow-node" { + t.Errorf("capacity show --json NodeID = %v, want jsonshow-node", c["NodeID"]) + } +} + +func TestNodeCapacityListEmpty(t *testing.T) { + _, cleanup := initTestEnv(t) + defer cleanup() + resetRootFlags(t) + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"node", "capacity", "list"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("capacity list: %v", err) + } + if !strings.Contains(buf.String(), "No capacity") { + t.Errorf("capacity list empty output unexpected: %s", buf.String()) + } +} + +func TestNodeCapacityListAfterSet(t *testing.T) { + _, cleanup := initTestEnv(t) + defer cleanup() + seedCapacity(t, "list-node", 5000, 4096, 51200) + resetRootFlags(t) + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"node", "capacity", "list"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("capacity list: %v", err) + } + if !strings.Contains(buf.String(), "list-node") { + t.Errorf("capacity list missing node: %s", buf.String()) + } +} + +func TestNodeCapacityListJSON(t *testing.T) { + _, cleanup := initTestEnv(t) + defer cleanup() + seedCapacity(t, "jsonlist-node", 5000, 4096, 51200) + resetRootFlags(t) + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"node", "capacity", "list", "--json"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("capacity list --json: %v", err) + } + var rows []map[string]any + if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &rows); err != nil { + t.Fatalf("unmarshal capacity list json: %v\n%s", err, buf.String()) + } + found := false + for _, r := range rows { + if r["NodeID"] == "jsonlist-node" { + found = true + } + } + if !found { + t.Errorf("capacity list --json missing jsonlist-node: %s", buf.String()) + } +} + +func seedCapacity(t *testing.T, nodeID string, cpu, mem, disk int64) { + t.Helper() + db, err := store.Open(certpaths.DBPath()) + if err != nil { + t.Fatalf("open db: %v", err) + } + defer db.Close() + repo := store.NewCapacityRepo(db) + c := &store.NodeCapacity{ + NodeID: nodeID, + CPUMillicores: cpu, + MemoryMiB: mem, + DiskMiB: disk, + } + if err := repo.Upsert(t.Context(), c); err != nil { + t.Fatalf("upsert capacity: %v", err) + } +} diff --git a/internal/cli/node_test.go b/internal/cli/node_test.go new file mode 100644 index 0000000..329488f --- /dev/null +++ b/internal/cli/node_test.go @@ -0,0 +1,320 @@ +// 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" + "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) +} diff --git a/internal/cli/status_test.go b/internal/cli/status_test.go new file mode 100644 index 0000000..5d394af --- /dev/null +++ b/internal/cli/status_test.go @@ -0,0 +1,47 @@ +package cli + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +func TestStatusText(t *testing.T) { + resetRootFlags(t) + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"status"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("status: %v", err) + } + out := buf.String() + if !strings.Contains(out, "orca daemon status") { + t.Errorf("status text output unexpected: %s", out) + } + if !strings.Contains(out, "version") { + t.Errorf("status output missing version: %s", out) + } +} + +func TestStatusJSON(t *testing.T) { + resetRootFlags(t) + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"status", "--json"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("status --json: %v", err) + } + var info map[string]any + if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &info); err != nil { + t.Fatalf("unmarshal status json: %v\n%s", err, buf.String()) + } + if info["daemon"] != "stopped" { + t.Errorf("status json daemon = %v, want stopped", info["daemon"]) + } + if info["api_addr"] != "https://localhost:8443" { + t.Errorf("status json api_addr = %v, want https://localhost:8443", info["api_addr"]) + } +} diff --git a/internal/cli/version_test.go b/internal/cli/version_test.go new file mode 100644 index 0000000..8df2c1e --- /dev/null +++ b/internal/cli/version_test.go @@ -0,0 +1,47 @@ +package cli + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +func TestVersionText(t *testing.T) { + resetRootFlags(t) + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"version"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("version: %v", err) + } + out := buf.String() + if !strings.Contains(out, "orca version") { + t.Errorf("version text output unexpected: %s", out) + } + if !strings.Contains(out, "git commit") { + t.Errorf("version output missing git commit: %s", out) + } +} + +func TestVersionJSON(t *testing.T) { + resetRootFlags(t) + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"version", "--json"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("version --json: %v", err) + } + var info map[string]string + if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &info); err != nil { + t.Fatalf("unmarshal version json: %v\n%s", err, buf.String()) + } + if info["version"] == "" { + t.Errorf("version json missing version field: %v", info) + } + if info["git_commit"] == "" { + t.Errorf("version json missing git_commit field: %v", info) + } +}