a73x

internal/cli/sshcmd_test.go

Ref:   Size: 11.0 KiB   History

package cli

import (
	"net/http"
	"net/http/httptest"
	"slices"
	"strings"
	"testing"
)

func testEnv() Env {
	return Env{
		URL: "http://s:8080", Gate: "gate.example:2222", Tenant: "default",
		Key: "/home/u/.ssh/id_ed25519", KnownHosts: "/home/u/.ssh/eitri_known_hosts",
	}
}

// The argv shape is load-bearing (two verified hops); pin it exactly.
func TestSSHArgvTwoVerifiedHops(t *testing.T) {
	got := SSHArgv(testEnv(), "dev", nil)
	wantProxy := "ssh -W %h:%p -o StrictHostKeyChecking=yes" +
		" -o UserKnownHostsFile='/home/u/.ssh/eitri_known_hosts'" +
		" -i '/home/u/.ssh/id_ed25519' -p 2222 ubuntu@gate.example"
	want := []string{
		"ssh",
		"-o", "ProxyCommand=" + wantProxy,
		"-o", "StrictHostKeyChecking=yes",
		"-o", "UserKnownHostsFile=/home/u/.ssh/eitri_known_hosts",
		"-i", "/home/u/.ssh/id_ed25519",
		"ubuntu@default.dev",
	}
	if !slices.Equal(got, want) {
		t.Errorf("argv:\n got %q\nwant %q", got, want)
	}
}

// The wire name is ALWAYS the namespaced <tenant>.<vm> — a VM host cert's one
// principal — so SSHArgv never emits a bare target.
func TestSSHArgvAlwaysNamespaced(t *testing.T) {
	got := SSHArgv(testEnv(), "dev", nil)
	if want := "ubuntu@default.dev"; got[len(got)-1] != want {
		t.Errorf("target = %q, want %q", got[len(got)-1], want)
	}
}

// resolvePlane derives the tenant from the credential (via /me) when nothing
// pins it, so the namespaced connect name can be built without the user knowing
// their tenant.
func TestResolvePlaneTenantFromMe(t *testing.T) {
	var gotAuth string
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gotAuth = r.Header.Get("Authorization")
		if r.URL.Path != "/api/v1/me" {
			t.Errorf("path = %q, want /api/v1/me", r.URL.Path)
		}
		w.Write([]byte(`{"tenant":"acme","email":"me@acme.example"}`))
	}))
	defer srv.Close()
	t.Setenv("EITRI_TOKEN", "tok123")

	e := testEnv()
	e.URL = srv.URL
	e.Tenant = ""
	got, err := resolvePlane(e)
	if err != nil {
		t.Fatal(err)
	}
	if got.Tenant != "acme" {
		t.Errorf("tenant = %q, want acme", got.Tenant)
	}
	if gotAuth != "Bearer tok123" {
		t.Errorf("auth = %q, want Bearer tok123", gotAuth)
	}
}

// TestResolvePlaneGateOrder pins the gate's whole precedence chain in one place.
// A pinned gate (EITRI_GATE, or the config file — both reach here as a non-empty
// Env.Gate) outranks even a plane that names a different one; unpinned, the
// plane's own answer is taken; and a self-hosted plane that names none is an
// error rather than a hop through the hosted gate. The hosted rung below is
// pinned by TestGateFor, which needs no server to reach the hosted URL.
func TestResolvePlaneGateOrder(t *testing.T) {
	t.Setenv("EITRI_TOKEN", "tok")
	for _, tc := range []struct {
		name   string
		pinned string // Env.Gate as the env or the config file left it
		meBody string
		want   string
	}{
		{
			name:   "a pinned gate outranks the plane's own answer",
			pinned: "gate.mine:2222",
			meBody: `{"tenant":"acme","ssh_gate":"gate.acme.example:2222"}`,
			want:   "gate.mine:2222",
		},
		{
			name:   "unpinned, the plane names its own gate",
			meBody: `{"tenant":"acme","ssh_gate":"gate.acme.example:2222"}`,
			want:   "gate.acme.example:2222",
		},
	} {
		t.Run(tc.name, func(t *testing.T) {
			srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
				w.Write([]byte(tc.meBody))
			}))
			defer srv.Close()

			e := testEnv()
			e.URL, e.Gate, e.Tenant = srv.URL, tc.pinned, ""
			got, err := resolvePlane(e)
			if err != nil {
				t.Fatal(err)
			}
			if got.Gate != tc.want {
				t.Errorf("gate = %q, want %q", got.Gate, tc.want)
			}
			if got.Tenant != "acme" {
				t.Errorf("tenant = %q, want acme", got.Tenant)
			}
		})
	}
}

// TestGateFor pins the one rung that is scoped to a single plane. The hosted
// gate address is knowledge about eitri.sh, not about planes in general: applied
// to a self-hosted one it is not a weak guess but a wrong answer that looks
// right, and it fails later as a host-key refusal rather than as the missing
// setting it is. So it is offered for the hosted URL and refused elsewhere.
func TestGateFor(t *testing.T) {
	for _, tc := range []struct {
		name        string
		meGate, url string
		want        string
		wantErr     bool
	}{
		{
			name:   "the plane's own answer needs no default at all",
			meGate: "gate.acme.example:2222", url: "https://eitri.acme.example",
			want: "gate.acme.example:2222",
		},
		{
			name: "the hosted plane naming none gets the hosted gate",
			url:  defaultURL, want: defaultGate,
		},
		{
			name: "a trailing slash is the same hosted plane",
			url:  defaultURL + "/", want: defaultGate,
		},
		{
			name: "a self-hosted plane naming none is an error, not a guess",
			url:  "https://eitri.acme.example", wantErr: true,
		},
	} {
		t.Run(tc.name, func(t *testing.T) {
			got, err := gateFor(tc.meGate, tc.url)
			if tc.wantErr {
				if err == nil {
					t.Fatalf("want an error, got gate %q", got)
				}
				for _, want := range []string{tc.url, "EITRI_GATE", "ssh_gate_domain"} {
					if !strings.Contains(err.Error(), want) {
						t.Errorf("error must name %q: %v", want, err)
					}
				}
				if strings.Contains(err.Error(), defaultGate) {
					t.Errorf("the error must not still suggest the hosted gate: %v", err)
				}
				return
			}
			if err != nil {
				t.Fatal(err)
			}
			if got != tc.want {
				t.Errorf("gate = %q, want %q", got, tc.want)
			}
		})
	}
}

// Both values pinned is the offline path: nothing to ask, so nothing is asked.
func TestResolvePlaneFullyPinnedShortCircuits(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
		t.Error("a pinned tenant and gate must short-circuit — no /me call")
	}))
	defer srv.Close()

	e := testEnv()
	e.URL, e.Tenant = srv.URL, "team"
	got, err := resolvePlane(e)
	if err != nil {
		t.Fatal(err)
	}
	if got.Tenant != "team" || got.Gate != "gate.example:2222" {
		t.Errorf("resolved = %+v", got)
	}
}

// A token is a credential, not an instruction to spend a round trip. Holding
// one must never cost a call that not holding one would have skipped: the
// tokenless path below resolves a pinned tenant on the hosted plane offline, so
// this one has to as well, or `eitri ssh` breaks on a plane the user cannot
// currently reach — and breaks it for exactly the laptops that are best set up.
func TestResolvePlaneATokenCostsNoNetworkCall(t *testing.T) {
	t.Setenv("EITRI_TOKEN", "tok")

	// There is no test server to point the hosted case at: the hosted rung is
	// scoped to the hosted URL by name (see gateFor), so a /me here would leave
	// the machine for console.eitri.sh. Which is what makes the assertion
	// sound — that call answers 401 to this token, and finds no network at all
	// in CI, so a regression fails here either way rather than passing quietly.
	e := testEnv()
	e.URL, e.Gate, e.Tenant = defaultURL, "", "team"
	got, err := resolvePlane(e)
	if err != nil {
		t.Fatalf("a pinned tenant on the hosted plane must resolve without asking: %v", err)
	}
	if got.Tenant != "team" || got.Gate != defaultGate {
		t.Errorf("resolved = %+v, want tenant team on %s", got, defaultGate)
	}

	// With the gate pinned too there IS something to prove it against: a live
	// server that fails this test if a single request reaches it.
	untouched := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
		t.Error("a tenant and gate that resolve here must reach the plane for nothing")
	}))
	defer untouched.Close()
	e.URL, e.Gate = untouched.URL, "gate.example:2222"
	if got, err = resolvePlane(e); err != nil || got.Gate != "gate.example:2222" {
		t.Errorf("resolved = %+v, err = %v", got, err)
	}
}

// A pinned tenant with no credential still needs a gate, and there is nobody to
// ask. Against the hosted plane the assumption is made without a call; against
// any other one there is nothing to assume, and the error says what to set.
func TestResolvePlaneNoTokenPinnedTenant(t *testing.T) {
	t.Setenv("EITRI_TOKEN", "")
	e := testEnv()
	e.URL, e.Gate, e.Tenant = defaultURL, "", "team"
	got, err := resolvePlane(e)
	if err != nil {
		t.Fatal(err)
	}
	if got.Gate != defaultGate {
		t.Errorf("gate = %q, want %q", got.Gate, defaultGate)
	}

	e.URL = "https://eitri.acme.example"
	if _, err := resolvePlane(e); err == nil || !strings.Contains(err.Error(), "names no SSH gate") {
		t.Errorf("a self-hosted plane must not be given the hosted gate: %v", err)
	}
}

// A credential that resolves to no tenant, or a failing /me, is a clear error.
func TestResolvePlaneMeFailures(t *testing.T) {
	t.Setenv("EITRI_TOKEN", "tok")

	// Empty tenant in the /me response.
	empty := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte(`{"tenant":"","email":"x@y"}`))
	}))
	defer empty.Close()
	e := testEnv()
	e.URL, e.Tenant = empty.URL, ""
	if _, err := resolvePlane(e); err == nil || !strings.Contains(err.Error(), "no tenant") {
		t.Errorf("empty tenant: got %v", err)
	}

	// A non-2xx /me surfaces as an error naming the resolution step.
	boom := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		http.Error(w, "nope", http.StatusUnauthorized)
	}))
	defer boom.Close()
	e.URL = boom.URL
	if _, err := resolvePlane(e); err == nil || !strings.Contains(err.Error(), "resolving your tenant") {
		t.Errorf("me error: got %v", err)
	}
}

// With no tenant and no credential the connect name cannot be built; the error
// names every remedy before any network call.
func TestResolvePlaneNoTokenNoTenant(t *testing.T) {
	t.Setenv("EITRI_TOKEN", "")
	e := testEnv()
	e.Tenant = ""
	_, err := resolvePlane(e)
	if err == nil || !strings.Contains(err.Error(), "eitri init") ||
		!strings.Contains(err.Error(), "EITRI_TOKEN") || !strings.Contains(err.Error(), "EITRI_TENANT") {
		t.Errorf("want error naming init, EITRI_TOKEN and EITRI_TENANT, got %v", err)
	}
}

func TestSSHArgvGateDefaultPort(t *testing.T) {
	e := testEnv()
	e.Gate = "gate.example"
	got := SSHArgv(e, "dev", nil)
	wantProxy := "ssh -W %h:%p -o StrictHostKeyChecking=yes" +
		" -o UserKnownHostsFile='/home/u/.ssh/eitri_known_hosts'" +
		" -i '/home/u/.ssh/id_ed25519' -p 22 ubuntu@gate.example"
	if got[2] != "ProxyCommand="+wantProxy {
		t.Errorf("proxy = %q, want %q", got[2], "ProxyCommand="+wantProxy)
	}
}

func TestSSHArgvIPv6Gate(t *testing.T) {
	e := testEnv()
	e.Gate = "[::1]:2222"
	got := SSHArgv(e, "dev", nil)
	if want := " -p 2222 ubuntu@::1"; !slices.ContainsFunc(got, func(s string) bool {
		return len(s) > len(want) && s[len(s)-len(want):] == want
	}) {
		t.Errorf("ipv6 gate not split host/port correctly: %q", got[2])
	}
}

func TestSSHArgvSpacedPathsAreQuoted(t *testing.T) {
	e := testEnv()
	e.Key = "/Users/My Name/.ssh/id_ed25519"
	got := SSHArgv(e, "dev", nil)
	if want := "-i '/Users/My Name/.ssh/id_ed25519'"; !slices.ContainsFunc(got, func(s string) bool {
		return strings.Contains(s, want)
	}) {
		t.Errorf("spaced key path not quoted in proxy: %q", got[2])
	}
}

func TestSSHArgvExtraArgsPassThrough(t *testing.T) {
	got := SSHArgv(testEnv(), "dev", []string{"uptime", "-p"})
	n := len(got)
	if got[n-2] != "uptime" || got[n-1] != "-p" {
		t.Errorf("extra args not trailing: %v", got[n-3:])
	}
}