20523ac045
---ci--- project: orca phase: 7 milestone: v0.12 status: execute ---/ci--- R-021 invariant: no passwords, no Orca-issued tokens, no CA-key passphrases anywhere in the system. Removed: - proxmox/bootstrap.go: ssh.Password auth -> ssh.PublicKeys (key-based). --password/ removed from node join; replaced with --ssh-key (default: orca SSH key). Pre-staged key required. - stepca/stepca.go: --password-file /dev/stdin removed from Init and issueCert. Provisioner changed to 'orca-oidc' (OIDC provisioner). - identity/spiffe.go: --password-file removed from MintSVID. Provisioner changed to 'orca-oidc'. Tests: all proxmox, stepca, identity, cli tests updated + pass. 3 new password-rejection regression tests. Fake SSH server gains PublicKeyCallback. go vet clean. Full build green.
516 lines
15 KiB
Go
516 lines
15 KiB
Go
// 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", "--ssh-key", "/tmp/nonexistent-key"})
|
|
if err := rootCmd.Execute(); err == nil {
|
|
t.Fatal("expected error for proxmox without --host, got nil")
|
|
}
|
|
}
|
|
|
|
func TestNodeJoinProxmoxMissingSSHKey(t *testing.T) {
|
|
_, cleanup := initTestEnv(t)
|
|
defer cleanup()
|
|
resetRootFlags(t)
|
|
var buf bytes.Buffer
|
|
rootCmd.SetOut(&buf)
|
|
rootCmd.SetErr(&buf)
|
|
// No --ssh-key and no default orca key -> error (R-021).
|
|
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 ssh-key, 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
|
|
}
|
|
|
|
// TestNodeJoinHostKeyFingerprintRequiresProxmox verifies T02.11:
|
|
// `orca node join --type linux --host-key-fingerprint SHA256:...`
|
|
// fails with a clear error from the D-044 RunE check. Exercises the
|
|
// cobra Execute() error path end-to-end.
|
|
func TestNodeJoinHostKeyFingerprintRequiresProxmox(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", "linux",
|
|
"--name", "linux-node",
|
|
"--host-key-fingerprint", "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
|
})
|
|
err := rootCmd.Execute()
|
|
if err == nil {
|
|
t.Fatal("expected error for --host-key-fingerprint without --type proxmox, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "--host-key-fingerprint requires --type proxmox") {
|
|
t.Errorf("error should mention the --host-key-fingerprint/--type proxmox requirement, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestNodeJoinHostKeyFingerprintProxmoxAccepted verifies that
|
|
// --host-key-fingerprint IS accepted for --type proxmox (the RunE check
|
|
// does not reject a proxmox-type join that pins the host key). This is
|
|
// the negative-space companion to TestNodeJoinHostKeyFingerprintRequiresProxmox
|
|
// (T02.11): the validation must only reject non-proxmox types.
|
|
//
|
|
// We can't run the full bootstrap without a real SSH server, so we
|
|
// assert that the RunE check passes (no "requires --type proxmox"
|
|
// error) and the failure — if any — comes from a later stage (missing
|
|
// --host / ssh-key), not the D-044 guard.
|
|
func TestNodeJoinHostKeyFingerprintProxmoxAccepted(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-key-fingerprint", "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
|
})
|
|
err := rootCmd.Execute()
|
|
if err == nil {
|
|
t.Fatal("expected a later-stage error (missing --host), got nil")
|
|
}
|
|
if strings.Contains(err.Error(), "requires --type proxmox") {
|
|
t.Errorf("D-044 guard wrongly rejected proxmox type: %v", err)
|
|
}
|
|
}
|
|
|
|
// --- REQ-146 / R-021 password removal regression test ---
|
|
|
|
// TestNodeJoinProxmoxPasswordRejected verifies the --password flag is
|
|
// no longer accepted (R-021: no passwords). The flag is removed; the
|
|
// CLI should reject it as an unknown flag.
|
|
func TestNodeJoinProxmoxPasswordRejected(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", "--password", "secret"})
|
|
if err := rootCmd.Execute(); err == nil {
|
|
t.Fatal("expected error for --password (R-021: no passwords), got nil")
|
|
}
|
|
}
|