a73x

internal/cli/init_test.go

Ref:   Size: 19.7 KiB   History

package cli

import (
	"context"
	"encoding/json"
	"errors"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"strings"
	"testing"

	"golang.org/x/crypto/ssh"
)

// fakePlane stands in for the control plane across init's three calls: who am
// I, which CAs does my tenant have, and register this one. A registration is
// visible to the next list, exactly as the real server behaves — which is what
// makes the second-run assertions mean anything. It records every upload, so a
// test can assert on the step that changed something AND on the step that
// deliberately did not.
type fakePlane struct {
	url      string
	me       map[string]any
	cas      []map[string]string
	uploaded []map[string]string
	status   int // non-zero: every request fails with this status
}

func newFakePlane(t *testing.T, me map[string]any, cas ...map[string]string) *fakePlane {
	t.Helper()
	f := &fakePlane{me: me, cas: cas}
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if f.status != 0 {
			http.Error(w, "computer says no", f.status)
			return
		}
		switch {
		case r.URL.Path == "/api/v1/me":
			json.NewEncoder(w).Encode(f.me)
		case r.URL.Path == "/api/v1/user-cas" && r.Method == http.MethodGet:
			if f.cas == nil {
				f.cas = []map[string]string{}
			}
			json.NewEncoder(w).Encode(f.cas)
		case r.URL.Path == "/api/v1/user-cas" && r.Method == http.MethodPost:
			var body map[string]string
			if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
				t.Errorf("upload body: %v", err)
			}
			f.uploaded = append(f.uploaded, body)
			pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(body["public_key"]))
			if err != nil {
				t.Errorf("uploaded a key the server cannot parse: %v", err)
				http.Error(w, "invalid public_key", http.StatusBadRequest)
				return
			}
			fp := ssh.FingerprintSHA256(pub)
			f.cas = append(f.cas, map[string]string{"fingerprint": fp, "label": body["label"], "pubkey": body["public_key"]})
			w.WriteHeader(http.StatusCreated)
			json.NewEncoder(w).Encode(map[string]string{"fingerprint": fp})
		default:
			t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
			http.Error(w, "not found", http.StatusNotFound)
		}
	}))
	t.Cleanup(srv.Close)
	f.url = srv.URL
	return f
}

func meBody(tenant, gate string) map[string]any {
	return map[string]any{"tenant": tenant, "email": tenant + "@example.test", "ssh_gate": gate}
}

// initEnv is a laptop with nothing on it: an empty home, no EITRI_* variables,
// and paths that exist only inside the test.
func initEnv(t *testing.T, plane *fakePlane) (Env, string) {
	t.Helper()
	cfg := cleanEnv(t)
	home := filepath.Dir(cfg)
	return Env{
		URL:        plane.url,
		CA:         filepath.Join(home, ".ssh", "eitri_user_ca"),
		Key:        filepath.Join(home, ".ssh", "id_ed25519"),
		KnownHosts: filepath.Join(home, ".ssh", "eitri_known_hosts"),
	}, cfg
}

// runInitWith drives one init to completion over canned answers, returning the
// transcript the user would have seen.
func runInitWith(t *testing.T, e Env, cfg, answers string) (string, error) {
	t.Helper()
	var out strings.Builder
	err := RunInit(context.Background(), e, cfg, "tok", strings.NewReader(answers), &out)
	return out.String(), err
}

// The empty-laptop path: a tenant with no CA and no key on disk is offered a
// generated one, registers it, and gets a config that makes every later command
// need no environment.
func TestInitFirstRunGeneratesAndRegisters(t *testing.T) {
	plane := newFakePlane(t, meBody("acme", "gate.acme.test:2222"))
	e, cfg := initEnv(t, plane)

	// identity, no existing key elsewhere, generate, register, default label, write.
	out, err := runInitWith(t, e, cfg, "y\nn\ny\ny\n\ny\n")
	if err != nil {
		t.Fatalf("%v\n%s", err, out)
	}

	if len(plane.uploaded) != 1 {
		t.Fatalf("uploads = %d, want 1\n%s", len(plane.uploaded), out)
	}
	// eitri gets the public half and nothing else.
	if !strings.HasPrefix(plane.uploaded[0]["public_key"], "ssh-ed25519 ") {
		t.Errorf("uploaded %q, want an ed25519 public key line", plane.uploaded[0]["public_key"])
	}
	if strings.Contains(plane.uploaded[0]["public_key"], "PRIVATE") {
		t.Fatal("a private key left the machine")
	}

	priv, err := os.Stat(e.CA)
	if err != nil {
		t.Fatalf("CA private key: %v", err)
	}
	if perm := priv.Mode().Perm(); perm != 0o600 {
		t.Errorf("CA key mode = %o, want 600", perm)
	}
	if _, err := os.Stat(e.CA + ".pub"); err != nil {
		t.Errorf("CA public key: %v", err)
	}

	got, err := LoadConfig(cfg)
	if err != nil {
		t.Fatal(err)
	}
	want := Config{URL: plane.url, Gate: "gate.acme.test:2222", Tenant: "acme", CA: e.CA, Key: e.Key}
	if got != want {
		t.Errorf("config:\n got %+v\nwant %+v", got, want)
	}
	// The gate written is the one the PLANE named, not the hosted default.
	if strings.Contains(out, defaultGate) {
		t.Errorf("a plane that names its gate must not be second-guessed:\n%s", out)
	}
}

// Running init again against a settled laptop reports every step as already
// done and changes nothing — no second CA, no rewritten config.
func TestInitSecondRunChangesNothing(t *testing.T) {
	plane := newFakePlane(t, meBody("acme", "gate.acme.test:2222"))
	e, cfg := initEnv(t, plane)
	if _, err := runInitWith(t, e, cfg, "y\nn\ny\ny\n\ny\n"); err != nil {
		t.Fatal(err)
	}
	before, err := os.ReadFile(cfg)
	if err != nil {
		t.Fatal(err)
	}

	// Only the identity step asks anything the second time; every later "y"
	// here is spare, and if one is consumed the assertions below catch it.
	out, err := runInitWith(t, e, cfg, "y\ny\ny\ny\ny\n")
	if err != nil {
		t.Fatalf("%v\n%s", err, out)
	}
	if len(plane.uploaded) != 1 {
		t.Errorf("uploads = %d, want the first run's 1\n%s", len(plane.uploaded), out)
	}
	after, err := os.ReadFile(cfg)
	if err != nil {
		t.Fatal(err)
	}
	if string(after) != string(before) {
		t.Errorf("config rewritten:\n before %s\n after  %s", before, after)
	}
	if n := strings.Count(out, "Already done"); n != 2 {
		t.Errorf("want the CA and config steps both reporting already done, got %d:\n%s", n, out)
	}
}

// A key already on the laptop is offered for registration; init never generates
// a second one on top of it.
func TestInitPrefersAnExistingLocalKey(t *testing.T) {
	plane := newFakePlane(t, meBody("acme", "gate.acme.test:2222"))
	e, cfg := initEnv(t, plane)
	pub, err := newEd25519Key(e.CA)
	if err != nil {
		t.Fatal(err)
	}

	out, err := runInitWith(t, e, cfg, "y\ny\nlaptop\ny\n") // identity, register, label, write
	if err != nil {
		t.Fatalf("%v\n%s", err, out)
	}
	if strings.Contains(out, "Generate it?") {
		t.Errorf("a key already exists — init must not offer to generate:\n%s", out)
	}
	if len(plane.uploaded) != 1 {
		t.Fatalf("uploads = %d, want 1\n%s", len(plane.uploaded), out)
	}
	if got, want := plane.uploaded[0]["public_key"], string(ssh.MarshalAuthorizedKey(pub)); got != want {
		t.Errorf("uploaded the wrong key:\n got %q\nwant %q", got, want)
	}
	if plane.uploaded[0]["label"] != "laptop" {
		t.Errorf("label = %q, want laptop", plane.uploaded[0]["label"])
	}
}

// The trap: the tenant has CAs registered, and the key on this laptop is not
// one of them. Every cert it signs is well-formed and refused by every guest,
// and ssh reports only a denied public key — so init says it plainly rather
// than leaving the user to read that as a broken account.
func TestInitNamesAKeyNoGuestWillTrust(t *testing.T) {
	other, _, _, _, err := ssh.ParseAuthorizedKey([]byte(testUserCALine))
	if err != nil {
		t.Fatal(err)
	}
	plane := newFakePlane(t, meBody("acme", "gate.acme.test:2222"), map[string]string{
		"fingerprint": ssh.FingerprintSHA256(other), "label": "the other laptop", "pubkey": testUserCALine,
	})
	e, cfg := initEnv(t, plane)
	if _, err := newEd25519Key(e.CA); err != nil {
		t.Fatal(err)
	}

	// Decline the offer to register: the warning must stand on its own, and
	// nothing may be uploaded behind a "no".
	out, err := runInitWith(t, e, cfg, "y\nn\ny\n")
	if err != nil {
		t.Fatalf("%v\n%s", err, out)
	}
	if !strings.Contains(out, "not one of the above") || !strings.Contains(out, "refused by every guest") {
		t.Errorf("the mismatch must be named plainly:\n%s", out)
	}
	if !strings.Contains(out, ssh.FingerprintSHA256(other)) {
		t.Errorf("the registered CA's fingerprint must be shown:\n%s", out)
	}
	if len(plane.uploaded) != 0 {
		t.Errorf("declined, yet %d uploads happened", len(plane.uploaded))
	}
}

// A key that IS one of the tenant's registered CAs is recognized: nothing is
// uploaded, and the step says so.
func TestInitRecognizesTheRegisteredKey(t *testing.T) {
	plane := newFakePlane(t, meBody("acme", "gate.acme.test:2222"))
	e, cfg := initEnv(t, plane)
	pub, err := newEd25519Key(e.CA)
	if err != nil {
		t.Fatal(err)
	}
	plane.cas = []map[string]string{{
		"fingerprint": ssh.FingerprintSHA256(pub), "label": "this laptop",
		"pubkey": string(ssh.MarshalAuthorizedKey(pub)),
	}}

	out, err := runInitWith(t, e, cfg, "y\ny\n") // identity, write config
	if err != nil {
		t.Fatalf("%v\n%s", err, out)
	}
	if len(plane.uploaded) != 0 {
		t.Errorf("an already-registered key must not be uploaded again")
	}
	if !strings.Contains(out, "Already done") || !strings.Contains(out, "this laptop") {
		t.Errorf("want the registration recognized by label:\n%s", out)
	}
}

// Declining every step leaves the laptop exactly as it was found. Nothing in
// init happens by default, including by running out of input.
func TestInitDeclinedStepsTouchNothing(t *testing.T) {
	plane := newFakePlane(t, meBody("acme", "gate.acme.test:2222"))
	e, cfg := initEnv(t, plane)

	out, err := runInitWith(t, e, cfg, "y\nn\nn\nn\n") // identity, no key elsewhere, no generate, no write
	if err != nil {
		t.Fatalf("%v\n%s", err, out)
	}
	if _, err := os.Stat(e.CA); !os.IsNotExist(err) {
		t.Errorf("a CA was written despite the decline: %v", err)
	}
	if _, err := os.Stat(cfg); !os.IsNotExist(err) {
		t.Errorf("a config was written despite the decline: %v", err)
	}
	if len(plane.uploaded) != 0 {
		t.Errorf("%d uploads despite the decline", len(plane.uploaded))
	}
}

// An init run against a config that names a different plane says so before it
// overwrites, since the tenant and CA that follow belong to the new one.
func TestInitNamesAPlaneChange(t *testing.T) {
	plane := newFakePlane(t, meBody("acme", "gate.acme.test:2222"))
	e, cfg := initEnv(t, plane)
	if err := SaveConfig(cfg, Config{URL: "http://192.0.2.10:8080", Gate: "192.0.2.10:2222", Tenant: "old"}); err != nil {
		t.Fatal(err)
	}

	out, err := runInitWith(t, e, cfg, "y\nn\ny\ny\n\ny\n")
	if err != nil {
		t.Fatalf("%v\n%s", err, out)
	}
	if !strings.Contains(out, "different plane") || !strings.Contains(out, "http://192.0.2.10:8080") {
		t.Errorf("the plane change must be called out:\n%s", out)
	}
	if !strings.Contains(out, "(was old)") {
		t.Errorf("the tenant it replaces must be shown:\n%s", out)
	}
}

// A token minted by another plane fails here, and the message says that rather
// than leaving a bare 401 to be read as a bad password.
func TestInitBadTokenBlamesThePlane(t *testing.T) {
	plane := newFakePlane(t, meBody("acme", "gate.acme.test:2222"))
	plane.status = http.StatusUnauthorized
	e, cfg := initEnv(t, plane)

	out, err := runInitWith(t, e, cfg, "y\n")
	if err == nil {
		t.Fatalf("want an error:\n%s", out)
	}
	if !strings.Contains(err.Error(), plane.url) || !strings.Contains(err.Error(), "belongs to the plane that minted it") {
		t.Errorf("error must name the plane: %v", err)
	}
	if _, statErr := os.Stat(cfg); !os.IsNotExist(statErr) {
		t.Error("a failed identity step must write no config")
	}
}

// A self-hosted plane that names no gate gets no gate written: the hosted
// address belongs to the hosted plane, and writing it here would send every
// later session through eitri.sh to reach a guest that is nowhere near it. So
// init records nothing and says which two settings would fix it.
func TestInitWritesNoGateForAPlaneThatNamesNone(t *testing.T) {
	plane := newFakePlane(t, map[string]any{"tenant": "acme", "email": "me@acme.test"})
	e, cfg := initEnv(t, plane)

	out, err := runInitWith(t, e, cfg, "y\nn\ny\ny\n\ny\n")
	if err != nil {
		t.Fatalf("%v\n%s", err, out)
	}
	got, err := LoadConfig(cfg)
	if err != nil {
		t.Fatal(err)
	}
	if got.Gate != "" {
		t.Errorf("gate = %q, want none written", got.Gate)
	}
	if strings.Contains(out, defaultGate) {
		t.Errorf("the hosted gate must not be proposed for a self-hosted plane:\n%s", out)
	}
	if !strings.Contains(out, "ssh_gate_domain") || !strings.Contains(out, "EITRI_GATE") {
		t.Errorf("both remedies must be named:\n%s", out)
	}
}

// A gate already pinned here (EITRI_GATE, or the config from a previous run)
// survives a plane that names none: init keeps it rather than clearing it.
func TestInitKeepsAPinnedGateWhenThePlaneNamesNone(t *testing.T) {
	plane := newFakePlane(t, map[string]any{"tenant": "acme", "email": "me@acme.test"})
	e, cfg := initEnv(t, plane)
	e.Gate = "gate.acme.test:2222"

	out, err := runInitWith(t, e, cfg, "y\nn\ny\ny\n\ny\n")
	if err != nil {
		t.Fatalf("%v\n%s", err, out)
	}
	got, err := LoadConfig(cfg)
	if err != nil {
		t.Fatal(err)
	}
	if got.Gate != "gate.acme.test:2222" {
		t.Errorf("gate = %q, want the pinned one kept", got.Gate)
	}
}

// A pinned gate is a deliberate answer, and init is the command most likely to
// overwrite it: it is holding the plane's own answer, which for everyone who
// pinned nothing IS the right one. So the pin wins, in the same order `eitri
// ssh` resolves it — and because a file that already said the pin shows no diff
// when the pin is kept, the disagreement is said out loud instead.
func TestInitKeepsAPinnedGateAgainstAPlaneThatNamesAnother(t *testing.T) {
	for _, tc := range []struct{ name, envGate, wantSource string }{
		{name: "pinned by the environment", envGate: "gate.mine:2222", wantSource: "EITRI_GATE"},
		{name: "pinned by a previous run's config", wantSource: "the config file"},
	} {
		t.Run(tc.name, func(t *testing.T) {
			plane := newFakePlane(t, meBody("acme", "gate.acme.test:2222"))
			e, cfg := initEnv(t, plane)
			// FromEnv collapses both sources into Env.Gate; only the sentence
			// tells them apart, so only the variable differs here.
			e.Gate = "gate.mine:2222"
			if tc.envGate != "" {
				t.Setenv("EITRI_GATE", tc.envGate)
			}

			out, err := runInitWith(t, e, cfg, "y\nn\ny\ny\n\ny\n")
			if err != nil {
				t.Fatalf("%v\n%s", err, out)
			}
			got, err := LoadConfig(cfg)
			if err != nil {
				t.Fatal(err)
			}
			if got.Gate != "gate.mine:2222" {
				t.Errorf("gate = %q, want the pin kept", got.Gate)
			}
			want := tc.wantSource + " pins gate.mine:2222; this plane names gate.acme.test:2222 — keeping your pin."
			if !strings.Contains(out, want) {
				t.Errorf("the divergence must be shown, not silently resolved:\nwant %q\n%s", want, out)
			}
		})
	}
}

// The closed loop this breaks: a .pub copied to a second laptop, its
// fingerprint already registered by the laptop that made it. Accepting it would
// report the CA as settled, write a signing path with nothing at it, and leave
// every session dying in MintCert — while a second init reported the same thing
// again. A registration is not what lets a laptop connect; the private half is.
func TestInitRefusesARegisteredCAItCannotSignWith(t *testing.T) {
	other, _, _, _, err := ssh.ParseAuthorizedKey([]byte(testUserCALine))
	if err != nil {
		t.Fatal(err)
	}
	plane := newFakePlane(t, meBody("acme", "gate.acme.test:2222"), map[string]string{
		"fingerprint": ssh.FingerprintSHA256(other), "label": "the laptop that made it", "pubkey": testUserCALine,
	})
	e, cfg := initEnv(t, plane)

	// The public half, alone, where the user dropped it.
	pubOnly := filepath.Join(t.TempDir(), "ca.pub")
	if err := os.WriteFile(pubOnly, []byte(testUserCALine), 0o600); err != nil {
		t.Fatal(err)
	}
	signing := strings.TrimSuffix(pubOnly, ".pub")

	// identity, yes there is a key elsewhere, that .pub, no other path to try.
	out, err := runInitWith(t, e, cfg, "y\ny\n"+pubOnly+"\nn\n")
	if err == nil {
		t.Fatalf("want a refusal:\n%s", out)
	}
	if !strings.Contains(out, "the public half alone cannot sign") || !strings.Contains(out, signing) {
		t.Errorf("the transcript must name what is missing and where it belongs:\n%s", out)
	}
	if strings.Contains(out, "Already done") {
		t.Errorf("a CA this laptop cannot sign with is not already done:\n%s", out)
	}
	if len(plane.uploaded) != 0 {
		t.Errorf("%d uploads for a key that cannot sign", len(plane.uploaded))
	}
	if _, statErr := os.Stat(cfg); !os.IsNotExist(statErr) {
		t.Error("a config was written naming a CA with no signing half")
	}
}

// A path is typed, so it can be mistyped, and one bad line should not end a run
// that has already proved a token and listed a tenant's CAs.
func TestInitReoffersAfterAPathThatIsNotAKey(t *testing.T) {
	plane := newFakePlane(t, meBody("acme", "gate.acme.test:2222"))
	e, cfg := initEnv(t, plane)
	elsewhere := filepath.Join(t.TempDir(), "ca")
	pub, err := newEd25519Key(elsewhere)
	if err != nil {
		t.Fatal(err)
	}

	// identity, have a key, a path that is not there, try again, the real one,
	// register, default label, write.
	out, err := runInitWith(t, e, cfg, "y\ny\n"+elsewhere+"-typo\ny\n"+elsewhere+"\ny\n\ny\n")
	if err != nil {
		t.Fatalf("%v\n%s", err, out)
	}
	if len(plane.uploaded) != 1 {
		t.Fatalf("uploads = %d, want the second path's 1\n%s", len(plane.uploaded), out)
	}
	if got, want := plane.uploaded[0]["public_key"], string(ssh.MarshalAuthorizedKey(pub)); got != want {
		t.Errorf("registered the wrong key:\n got %q\nwant %q", got, want)
	}
	cfgGot, err := LoadConfig(cfg)
	if err != nil {
		t.Fatal(err)
	}
	if cfgGot.CA != elsewhere {
		t.Errorf("ca = %q, want the path that worked %q", cfgGot.CA, elsewhere)
	}
}

// A .pub is the half that travels — pasted into the console, copied between
// machines — so it is the half that can arrive alone, and it looks identical
// either way. What separates them is whether anything here can sign.
func TestCAPublicKeyRefusesAPublicHalfAlone(t *testing.T) {
	dir := t.TempDir()
	keyPath := filepath.Join(dir, "ca")
	if _, err := newEd25519Key(keyPath); err != nil {
		t.Fatal(err)
	}
	if err := os.Remove(keyPath); err != nil { // only the .pub was ever copied here
		t.Fatal(err)
	}

	_, _, err := caPublicKey(keyPath + ".pub")
	if !errors.Is(err, errNoPrivateHalf) {
		t.Fatalf("want a refusal naming the missing half, got %v", err)
	}
	if !strings.Contains(err.Error(), keyPath) {
		t.Errorf("the error must name where the private key belongs (%s): %v", keyPath, err)
	}
}

// A user may name their CA either way round — `eitri ca upload` takes the .pub,
// so that is what fingers reach for — and either way the config records the
// signing key, which is the half `eitri ssh` needs.
func TestCAPublicKeyAcceptsEitherHalf(t *testing.T) {
	dir := t.TempDir()
	keyPath := filepath.Join(dir, "ca")
	want, err := newEd25519Key(keyPath)
	if err != nil {
		t.Fatal(err)
	}
	for _, named := range []string{keyPath, keyPath + ".pub"} {
		pub, resolved, err := caPublicKey(named)
		if err != nil {
			t.Fatalf("%s: %v", named, err)
		}
		if ssh.FingerprintSHA256(pub) != ssh.FingerprintSHA256(want) {
			t.Errorf("%s: wrong key", named)
		}
		if resolved != keyPath {
			t.Errorf("%s: path = %q, want the signing key %q", named, resolved, keyPath)
		}
	}
	if _, _, err := caPublicKey(filepath.Join(dir, "absent")); !os.IsNotExist(err) {
		t.Errorf("a missing key must report as missing, got %v", err)
	}
}

// newEd25519Key never overwrites: an existing CA is an identity, and a silent
// replacement would strand every guest that trusts the old one.
func TestNewEd25519KeyRefusesToOverwrite(t *testing.T) {
	path := filepath.Join(t.TempDir(), "ca")
	if _, err := newEd25519Key(path); err != nil {
		t.Fatal(err)
	}
	before, err := os.ReadFile(path)
	if err != nil {
		t.Fatal(err)
	}
	if _, err := newEd25519Key(path); !os.IsExist(err) {
		t.Fatalf("want an exists error, got %v", err)
	}
	after, err := os.ReadFile(path)
	if err != nil {
		t.Fatal(err)
	}
	if string(after) != string(before) {
		t.Error("the existing key was replaced")
	}
}