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")
}
}