0d5ff663b4
---ci--- project: orca phase: 13 milestone: v0.12 status: execute ---/ci--- step-ca cert/key temp files moved from world-readable /tmp/orca-* to /etc/orca/step-tmp/orca-* (0700). mkdir + chmod 700 before writing. Fixes both stepca.go and spiffe.go. All tests updated + pass.
366 lines
12 KiB
Go
366 lines
12 KiB
Go
package stepca
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.cloudinit.dev/coreci/orca/internal/paths"
|
|
"git.cloudinit.dev/coreci/orca/internal/sshpush"
|
|
)
|
|
|
|
// mockExec is a record-and-replay execer for the stepca.Client. It
|
|
// stores every command it received keyed by a substring match, so a
|
|
// test can assert "Init ran `step ca init`" without coupling to
|
|
// exact-flag ordering. Each entry maps a substring the test expects
|
|
// to appear in the command to the output that should be returned.
|
|
type mockExec struct {
|
|
// responses is a list of (substring, output, err). The first
|
|
// matching entry wins; an entry with an empty substring matches
|
|
// any command (catch-all).
|
|
responses []mockResp
|
|
// calls records every command the client issued, in order.
|
|
calls []string
|
|
}
|
|
|
|
type mockResp struct {
|
|
match string
|
|
out []byte
|
|
err error
|
|
}
|
|
|
|
func (m *mockExec) Exec(ctx context.Context, peer string, cmd string) ([]byte, error) {
|
|
m.calls = append(m.calls, cmd)
|
|
for _, r := range m.responses {
|
|
if r.match == "" || strings.Contains(cmd, r.match) {
|
|
return r.out, r.err
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// newMockClient returns a Client wired to a mockExec and an ORCA_HOME
|
|
// under a temp dir (so paths.CACertPath() resolves to a writable path
|
|
// during Init tests).
|
|
func newMockClient(t *testing.T, lead string) (*Client, *mockExec) {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
t.Setenv("ORCA_HOME", dir)
|
|
mx := &mockExec{}
|
|
c := NewClient(nil, lead)
|
|
c.exec = mx
|
|
return c, mx
|
|
}
|
|
|
|
func containsCall(t *testing.T, mx *mockExec, want string) {
|
|
t.Helper()
|
|
for _, c := range mx.calls {
|
|
if strings.Contains(c, want) {
|
|
return
|
|
}
|
|
}
|
|
t.Errorf("no exec call contained %q; calls were:\n%s", want, strings.Join(mx.calls, "\n"))
|
|
}
|
|
|
|
func TestNewClient_Defaults(t *testing.T) {
|
|
tr := sshpush.NewTransport("/tmp/key", "/tmp/kh")
|
|
c := NewClient(tr, "lead:22")
|
|
if c.leadPeer != "lead:22" {
|
|
t.Errorf("leadPeer = %q", c.leadPeer)
|
|
}
|
|
if c.transport != tr {
|
|
t.Error("transport not stored")
|
|
}
|
|
if c.exec == nil {
|
|
t.Error("exec seam is nil")
|
|
}
|
|
}
|
|
|
|
func TestClient_Preflight_LeadUnset(t *testing.T) {
|
|
c, _ := newMockClient(t, "")
|
|
if err := c.Init(context.Background(), "n", "d", "a"); !errors.Is(err, ErrLeadUnset) {
|
|
t.Errorf("Init with empty lead: err = %v, want ErrLeadUnset", err)
|
|
}
|
|
if _, _, err := c.IssueServerCert(context.Background(), "p", nil); !errors.Is(err, ErrLeadUnset) {
|
|
t.Errorf("IssueServerCert: err = %v, want ErrLeadUnset", err)
|
|
}
|
|
if _, _, err := c.IssueSVID(context.Background(), "spiffe://orca/x", nil); !errors.Is(err, ErrLeadUnset) {
|
|
t.Errorf("IssueSVID: err = %v, want ErrLeadUnset", err)
|
|
}
|
|
if err := c.RenewServerCert(context.Background(), "p"); !errors.Is(err, ErrLeadUnset) {
|
|
t.Errorf("RenewServerCert: err = %v, want ErrLeadUnset", err)
|
|
}
|
|
if _, err := c.Fingerprint(context.Background()); !errors.Is(err, ErrLeadUnset) {
|
|
t.Errorf("Fingerprint: err = %v, want ErrLeadUnset", err)
|
|
}
|
|
}
|
|
|
|
func TestClient_Preflight_NilExec(t *testing.T) {
|
|
c := &Client{leadPeer: "lead:22"} // exec is nil
|
|
if err := c.Init(context.Background(), "n", "d", "a"); err == nil {
|
|
t.Fatal("Init with nil exec: expected error, got nil")
|
|
}
|
|
}
|
|
|
|
func TestInit_Success(t *testing.T) {
|
|
c, mx := newMockClient(t, "lead:22")
|
|
caPEM := []byte("-----BEGIN CERTIFICATE-----\nFAKE\n-----END CERTIFICATE-----\n")
|
|
mx.responses = []mockResp{
|
|
{match: "step ca init", out: nil, err: nil},
|
|
{match: "cat '/etc/step-ca/certs/root_ca.crt'", out: caPEM, err: nil},
|
|
}
|
|
if err := c.Init(context.Background(), "orca", "ca.orca.local", ":8443"); err != nil {
|
|
t.Fatalf("Init: %v", err)
|
|
}
|
|
containsCall(t, mx, "step ca init --name 'orca'")
|
|
containsCall(t, mx, "--dns 'ca.orca.local'")
|
|
containsCall(t, mx, "--address ':8443'")
|
|
containsCall(t, mx, "--provisioner orca-oidc")
|
|
containsCall(t, mx, "--deployment-type standalone")
|
|
// Root CA mirrored to paths.CACertPath().
|
|
got, err := os.ReadFile(paths.CACertPath())
|
|
if err != nil {
|
|
t.Fatalf("read mirrored CA: %v", err)
|
|
}
|
|
if string(got) != string(caPEM) {
|
|
t.Errorf("mirrored CA = %q, want %q", got, caPEM)
|
|
}
|
|
}
|
|
|
|
func TestInit_StepCLIFails(t *testing.T) {
|
|
c, mx := newMockClient(t, "lead:22")
|
|
stepErr := errors.New("step: non-zero exit 1")
|
|
mx.responses = []mockResp{
|
|
{match: "step ca init", out: nil, err: stepErr},
|
|
}
|
|
err := c.Init(context.Background(), "orca", "ca.orca.local", ":8443")
|
|
if err == nil {
|
|
t.Fatal("Init: expected error, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "stepca: init") {
|
|
t.Errorf("err = %v, want wrapped 'stepca: init'", err)
|
|
}
|
|
}
|
|
|
|
func TestInit_EmptyRootCA(t *testing.T) {
|
|
c, mx := newMockClient(t, "lead:22")
|
|
mx.responses = []mockResp{
|
|
{match: "step ca init", out: nil, err: nil},
|
|
{match: "cat '/etc/step-ca/certs/root_ca.crt'", out: nil, err: nil},
|
|
}
|
|
err := c.Init(context.Background(), "orca", "ca.orca.local", ":8443")
|
|
if err == nil {
|
|
t.Fatal("Init with empty root CA: expected error, got nil")
|
|
}
|
|
if !errors.Is(err, ErrStepCLI) {
|
|
t.Errorf("err = %v, want ErrStepCLI", err)
|
|
}
|
|
}
|
|
|
|
func TestIssueServerCert_Success(t *testing.T) {
|
|
c, mx := newMockClient(t, "lead:22")
|
|
certPEM := []byte("SERVER-CERT-PEM")
|
|
keyPEM := []byte("SERVER-KEY-PEM")
|
|
mx.responses = []mockResp{
|
|
{match: "step ca certificate", out: nil, err: nil},
|
|
{match: "cat '/etc/orca/step-tmp/orca-peer1.crt'", out: certPEM, err: nil},
|
|
{match: "cat '/etc/orca/step-tmp/orca-peer1.key'", out: keyPEM, err: nil},
|
|
{match: "rm -f", out: nil, err: nil},
|
|
}
|
|
gotCert, gotKey, err := c.IssueServerCert(context.Background(), "peer1", []string{"peer1.orca.local", "10.0.0.1"})
|
|
if err != nil {
|
|
t.Fatalf("IssueServerCert: %v", err)
|
|
}
|
|
if gotCert != string(certPEM) {
|
|
t.Errorf("cert = %q", gotCert)
|
|
}
|
|
if gotKey != string(keyPEM) {
|
|
t.Errorf("key = %q", gotKey)
|
|
}
|
|
containsCall(t, mx, "step ca certificate 'peer1'")
|
|
containsCall(t, mx, "--not-after '2160h'")
|
|
containsCall(t, mx, "--san 'peer1.orca.local'")
|
|
containsCall(t, mx, "--san '10.0.0.1'")
|
|
// Server cert path must NOT pin a provisioner (uses default).
|
|
for _, call := range mx.calls {
|
|
if strings.HasPrefix(call, "step ca certificate") && strings.Contains(call, "--provisioner") {
|
|
t.Errorf("server cert should not pin provisioner; cmd: %s", call)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIssueSVID_Success(t *testing.T) {
|
|
c, mx := newMockClient(t, "lead:22")
|
|
spiffe := "spiffe://orca/ns/_defaults/job/web/alloc/0"
|
|
certPEM := []byte("SVID-CERT-PEM")
|
|
keyPEM := []byte("SVID-KEY-PEM")
|
|
mx.responses = []mockResp{
|
|
{match: "step ca certificate", out: nil, err: nil},
|
|
{match: "cat '/etc/orca/step-tmp/orca-spiffe-orca_ns__defaults_job_web_alloc_0.crt'", out: certPEM, err: nil},
|
|
{match: "cat '/etc/orca/step-tmp/orca-spiffe-orca_ns__defaults_job_web_alloc_0.key'", out: keyPEM, err: nil},
|
|
{match: "rm -f", out: nil, err: nil},
|
|
}
|
|
gotCert, gotKey, err := c.IssueSVID(context.Background(), spiffe, []string{"web.orca.local"})
|
|
if err != nil {
|
|
t.Fatalf("IssueSVID: %v", err)
|
|
}
|
|
if gotCert != string(certPEM) || gotKey != string(keyPEM) {
|
|
t.Errorf("cert/key mismatch")
|
|
}
|
|
containsCall(t, mx, "step ca certificate")
|
|
containsCall(t, mx, "--not-after '24h'")
|
|
containsCall(t, mx, "--provisioner 'orca-oidc'")
|
|
// SPIFFE ID is both the subject AND a SAN.
|
|
containsCall(t, mx, "--san '"+spiffe+"'")
|
|
}
|
|
|
|
func TestIssueServerCert_StepFails(t *testing.T) {
|
|
c, mx := newMockClient(t, "lead:22")
|
|
mx.responses = []mockResp{
|
|
{match: "step ca certificate", out: nil, err: errors.New("step: exit 1")},
|
|
}
|
|
_, _, err := c.IssueServerCert(context.Background(), "peer1", nil)
|
|
if err == nil || !strings.Contains(err.Error(), "stepca: issue") {
|
|
t.Errorf("err = %v, want wrapped 'stepca: issue'", err)
|
|
}
|
|
}
|
|
|
|
func TestIssueServerCert_ReadCertFails(t *testing.T) {
|
|
c, mx := newMockClient(t, "lead:22")
|
|
mx.responses = []mockResp{
|
|
{match: "step ca certificate", out: nil, err: nil},
|
|
{match: "cat '/etc/orca/step-tmp/orca-peer1.crt'", out: nil, err: errors.New("ssh: cat failed")},
|
|
{match: "cat '/etc/orca/step-tmp/orca-peer1.key'", out: nil, err: nil},
|
|
}
|
|
_, _, err := c.IssueServerCert(context.Background(), "peer1", nil)
|
|
if err == nil || !strings.Contains(err.Error(), "read") {
|
|
t.Errorf("err = %v, want wrapped 'read'", err)
|
|
}
|
|
}
|
|
|
|
func TestIssueServerCert_EmptyCert(t *testing.T) {
|
|
c, mx := newMockClient(t, "lead:22")
|
|
mx.responses = []mockResp{
|
|
{match: "step ca certificate", out: nil, err: nil},
|
|
{match: "cat '/etc/orca/step-tmp/orca-peer1.crt'", out: nil, err: nil},
|
|
{match: "cat '/etc/orca/step-tmp/orca-peer1.key'", out: []byte("KEY"), err: nil},
|
|
{match: "rm -f", out: nil, err: nil},
|
|
}
|
|
_, _, err := c.IssueServerCert(context.Background(), "peer1", nil)
|
|
if err == nil || !errors.Is(err, ErrStepCLI) {
|
|
t.Errorf("err = %v, want ErrStepCLI", err)
|
|
}
|
|
}
|
|
|
|
func TestRenewServerCert_Success(t *testing.T) {
|
|
c, mx := newMockClient(t, "lead:22")
|
|
mx.responses = []mockResp{
|
|
{match: "step ca renew", out: nil, err: nil},
|
|
}
|
|
if err := c.RenewServerCert(context.Background(), "peer1"); err != nil {
|
|
t.Fatalf("RenewServerCert: %v", err)
|
|
}
|
|
containsCall(t, mx, "step ca renew '/etc/orca/step-tmp/orca-peer1.crt' '/etc/orca/step-tmp/orca-peer1.key' --force")
|
|
}
|
|
|
|
func TestRenewServerCert_Fails(t *testing.T) {
|
|
c, mx := newMockClient(t, "lead:22")
|
|
mx.responses = []mockResp{
|
|
{match: "step ca renew", out: nil, err: errors.New("step: renew failed")},
|
|
}
|
|
err := c.RenewServerCert(context.Background(), "peer1")
|
|
if err == nil || !strings.Contains(err.Error(), "stepca: renew") {
|
|
t.Errorf("err = %v, want wrapped 'stepca: renew'", err)
|
|
}
|
|
}
|
|
|
|
func TestFingerprint_Success(t *testing.T) {
|
|
c, mx := newMockClient(t, "lead:22")
|
|
mx.responses = []mockResp{
|
|
{match: "step certificate fingerprint", out: []byte("a1b2c3d4e5f6\n"), err: nil},
|
|
}
|
|
fp, err := c.Fingerprint(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Fingerprint: %v", err)
|
|
}
|
|
if fp != "a1b2c3d4e5f6" {
|
|
t.Errorf("fp = %q, want a1b2c3d4e5f6 (trimmed)", fp)
|
|
}
|
|
containsCall(t, mx, "step certificate fingerprint '/etc/step-ca/certs/root_ca.crt'")
|
|
}
|
|
|
|
func TestFingerprint_Empty(t *testing.T) {
|
|
c, mx := newMockClient(t, "lead:22")
|
|
mx.responses = []mockResp{
|
|
{match: "step certificate fingerprint", out: []byte(""), err: nil},
|
|
}
|
|
_, err := c.Fingerprint(context.Background())
|
|
if err == nil || !errors.Is(err, ErrStepCLI) {
|
|
t.Errorf("err = %v, want ErrStepCLI", err)
|
|
}
|
|
}
|
|
|
|
func TestFingerprint_Fails(t *testing.T) {
|
|
c, mx := newMockClient(t, "lead:22")
|
|
mx.responses = []mockResp{
|
|
{match: "step certificate fingerprint", out: nil, err: errors.New("ssh: exec failed")},
|
|
}
|
|
_, err := c.Fingerprint(context.Background())
|
|
if err == nil || !strings.Contains(err.Error(), "stepca: fingerprint") {
|
|
t.Errorf("err = %v, want wrapped 'stepca: fingerprint'", err)
|
|
}
|
|
}
|
|
|
|
func TestInit_MkdirFails(t *testing.T) {
|
|
// Point ORCA_HOME at a path that cannot be created under to
|
|
// force MkdirAll failure. We use a file as the parent.
|
|
dir := t.TempDir()
|
|
blocker := filepath.Join(dir, "block")
|
|
if err := os.WriteFile(blocker, []byte("x"), 0o644); err != nil {
|
|
t.Fatalf("write blocker: %v", err)
|
|
}
|
|
t.Setenv("ORCA_HOME", filepath.Join(blocker, "sub"))
|
|
// Construct the client directly (not newMockClient, which
|
|
// resets ORCA_HOME to a fresh temp dir).
|
|
mx := &mockExec{}
|
|
caPEM := []byte("FAKE")
|
|
mx.responses = []mockResp{
|
|
{match: "step ca init", out: nil, err: nil},
|
|
{match: "cat '/etc/step-ca/certs/root_ca.crt'", out: caPEM, err: nil},
|
|
}
|
|
c := NewClient(nil, "lead:22")
|
|
c.exec = mx
|
|
err := c.Init(context.Background(), "orca", "ca.orca.local", ":8443")
|
|
if err == nil {
|
|
t.Fatal("Init: expected mkdir error, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "mkdir") {
|
|
t.Errorf("err = %v, want 'mkdir'", err)
|
|
}
|
|
}
|
|
|
|
func TestShellQuote(t *testing.T) {
|
|
got := shellQuote("a'b")
|
|
want := "'a'\\''b'"
|
|
if got != want {
|
|
t.Errorf("shellQuote = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestSanitize(t *testing.T) {
|
|
cases := []struct{ in, want string }{
|
|
{"spiffe://orca/ns/_defaults/job/web/alloc/0",
|
|
"spiffe-orca_ns__defaults_job_web_alloc_0"},
|
|
{"plain-host", "plain-host"},
|
|
{"a b", "a_b"},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := sanitize(tc.in); got != tc.want {
|
|
t.Errorf("sanitize(%q) = %q, want %q", tc.in, got, tc.want)
|
|
}
|
|
}
|
|
}
|