a73x

internal/mcpserver/tools_test.go

Ref:   Size: 39.3 KiB   History

package mcpserver

import (
	"context"
	"crypto/ed25519"
	"crypto/rand"
	"encoding/json"
	"errors"
	"fmt"
	"io/fs"
	"strings"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"golang.org/x/crypto/ssh"

	"github.com/a73x/eitri/internal/server/api/client"
	"github.com/a73x/eitri/internal/server/release"
)

type fakeToolsAPI struct {
	vms     []client.VM
	created []client.CreateVMRequest
	deleted []string
	// lifecycle sequence returned across successive ListVMs calls for the
	// created VM, letting tests script the create→ready wait.
	phases []string
	calls  int
	// number of leading ListVMs calls that fail (control-plane blip); after
	// they are exhausted the phases sequence takes over.
	listErrs int
	// what the scripted VM reports about its named network once ready: the
	// network it was created on, and the address that network's DHCP granted
	// (empty for a lease that has not landed).
	network   string
	networkIP string
	// exposures the fake serves per VM id, and the exposure ids revoked.
	exposures map[string][]client.Exposure
	revoked   []string
	// hosts the fake's fleet reports; nil means the one-host fleet.
	hosts []client.Host
	// CAs registered through ca_upload, as (line, label) pairs, plus an error
	// the registration can be made to fail with.
	cas   [][2]string
	caErr error
	// what tenant_info reads back
	userCAs    []client.UserCA
	listCAErr  error
	delegation *client.Delegation
}

// fleet is the fake's host list, defaulted so tests that don't care about
// placement get a single online host. Its agent is current, because placement
// rules an older one out and these tests are not about that.
func (f *fakeToolsAPI) fleet() []client.Host {
	if f.hosts != nil {
		return f.hosts
	}
	return []client.Host{{ID: "h1", Name: "mewtwo", Online: true, AgentVersion: currentAgent}}
}

// currentAgent is any version placement accepts: the floor is exactly the
// question CertifiesGuestHostKeys asks, so naming it here keeps the fixture
// from pinning a release literal of its own.
const currentAgent = release.FirstCertifiedHostKeys

func (f *fakeToolsAPI) ListVMs(ctx context.Context) ([]client.VM, error) {
	if f.listErrs > 0 {
		f.listErrs--
		return nil, fmt.Errorf("control plane unavailable")
	}
	if len(f.phases) > 0 {
		i := f.calls
		if i >= len(f.phases) {
			i = len(f.phases) - 1
		}
		f.calls++
		vm := client.VM{ID: "new1", Name: "claude-abc", Lifecycle: f.phases[i], Network: f.network}
		if f.phases[i] == "ready" {
			vm.AssignedIP = "10.77.1.9"
			vm.NetworkIP = f.networkIP
		}
		return append(append([]client.VM{}, f.vms...), vm), nil
	}
	return f.vms, nil
}
func (f *fakeToolsAPI) CreateVM(ctx context.Context, r client.CreateVMRequest) (client.CreateVMResponse, error) {
	f.created = append(f.created, r)
	return client.CreateVMResponse{ID: "new1", Name: "claude-abc"}, nil
}
func (f *fakeToolsAPI) DeleteVM(ctx context.Context, id string) error {
	f.deleted = append(f.deleted, id)
	return nil
}
func (f *fakeToolsAPI) ListHosts(ctx context.Context) ([]client.Host, error) {
	return f.fleet(), nil
}

// FirstEligibleHost runs the real placement rule over the fake's fleet, so a
// test cannot pass against a kinder stand-in than the one in production.
func (f *fakeToolsAPI) FirstEligibleHost(ctx context.Context, network string) (client.Host, error) {
	return eligibleHost(f.fleet(), network)
}

// CreateExposure mirrors the control plane: host port 0 is allocated from the
// reserved range, an unnamed protocol is tcp, and the host address comes back
// with the grant.
func (f *fakeToolsAPI) CreateExposure(ctx context.Context, vmID string, guestPort, hostPort int64, protocol string) (client.Exposure, error) {
	if hostPort == 0 {
		hostPort = 30000 + int64(len(f.exposures[vmID]))
	}
	if protocol == "" {
		protocol = "tcp"
	}
	e := client.Exposure{
		ID: fmt.Sprintf("x-%d", len(f.exposures[vmID])+1), VMID: vmID, HostID: "h1",
		GuestPort: guestPort, HostPort: hostPort, HostAddr: "10.0.0.4",
		Protocol: protocol, Scope: "host", State: "pending",
	}
	if f.exposures == nil {
		f.exposures = map[string][]client.Exposure{}
	}
	f.exposures[vmID] = append(f.exposures[vmID], e)
	return e, nil
}
func (f *fakeToolsAPI) ListExposures(ctx context.Context, vmID string) ([]client.Exposure, error) {
	return f.exposures[vmID], nil
}
func (f *fakeToolsAPI) DeleteExposure(ctx context.Context, id string) error {
	f.revoked = append(f.revoked, id)
	return nil
}
func (f *fakeToolsAPI) ListUserCAs(ctx context.Context, tenant string) ([]client.UserCA, error) {
	return f.userCAs, f.listCAErr
}
func (f *fakeToolsAPI) Delegation(ctx context.Context) (client.Delegation, error) {
	if f.delegation == nil {
		return client.Delegation{}, errors.New("no live delegation for this tenant")
	}
	return *f.delegation, nil
}
func (f *fakeToolsAPI) RegisterUserCA(ctx context.Context, caLine, label string) error {
	if f.caErr != nil {
		return f.caErr
	}
	f.cas = append(f.cas, [2]string{caLine, label})
	return nil
}

type fakeRunner struct {
	execs []string
	out   ExecResult
	err   error
	// number of leading Exec calls that fail with a connection-style error (the
	// guest still booting, pre-sshd); after they are exhausted the configured
	// out/err takes over. Mirrors fakeToolsAPI.listErrs.
	execErrs int
	files    map[string][]byte
	reads    []string // vmName+"|"+path passed to ReadFile, in order
}

func (f *fakeRunner) Exec(ctx context.Context, vmName, cmd string, timeout time.Duration) (ExecResult, error) {
	f.execs = append(f.execs, vmName+"|"+cmd)
	if f.execErrs > 0 {
		f.execErrs--
		return ExecResult{}, fmt.Errorf("vm %s unreachable: connect: no route to host", vmName)
	}
	return f.out, f.err
}
func (f *fakeRunner) WriteFile(ctx context.Context, vmName, p string, data []byte, mode fs.FileMode) error {
	if f.files == nil {
		f.files = map[string][]byte{}
	}
	f.files[p] = data
	return nil
}
func (f *fakeRunner) ReadFile(ctx context.Context, vmName, p string) ([]byte, bool, error) {
	f.reads = append(f.reads, vmName+"|"+p)
	d, ok := f.files[p]
	if !ok {
		return nil, false, fmt.Errorf("open %s: not found", p)
	}
	return d, false, nil
}

// ConnectName mirrors GateAuth: the gate connect name is <tenant>.<vmName>, and
// the fake's tenant is "default" (as the fake CertAuthority returns).
func (f *fakeRunner) ConnectName(ctx context.Context, vmName string) (string, error) {
	return "default." + vmName, nil
}

func newTestTools(api *fakeToolsAPI, r *fakeRunner) *Tools {
	return &Tools{
		API: api, Runner: r,
		Gate:   "localhost:2223",
		VMUser: "ubuntu",
		// Fast tests, and a budget that ENDS. At the 10-minute default a test
		// whose VM never reaches the state it is waiting for does not fail —
		// it hangs, and CI pays ten minutes to learn what the timeout error
		// says in seconds. The two tests that assert on exhausting the budget
		// shorten it further themselves.
		PollEvery:   time.Millisecond,
		WaitTimeout: 5 * time.Second,
	}
}

// Every test above hands Tools a short budget, so the shipped defaults are
// exercised nowhere else — and they are the ones production runs on: a poll
// interval and a ceiling that a real create, downloading an image over a slow
// link, spends minutes inside.
func TestUnconfiguredToolsUseTheShippedWaitBudget(t *testing.T) {
	var zero Tools
	assert.Equal(t, 2*time.Second, zero.pollEvery())
	assert.Equal(t, 10*time.Minute, zero.waitTimeout())

	set := Tools{PollEvery: time.Millisecond, WaitTimeout: time.Second}
	assert.Equal(t, time.Millisecond, set.pollEvery(), "a configured interval wins over the default")
	assert.Equal(t, time.Second, set.waitTimeout())
}

func TestCreateWaitsForReadyAndCloudInit(t *testing.T) {
	api := &fakeToolsAPI{phases: []string{"creating", "creating", "ready"}}
	run := &fakeRunner{out: ExecResult{ExitCode: 0}}
	tl := newTestTools(api, run)

	out, err := tl.VMCreate(t.Context(), VMCreateIn{})
	require.NoError(t, err)
	assert.Equal(t, "new1", out.ID)
	assert.Equal(t, "10.77.1.9", out.IP)
	assert.Empty(t, out.CloudInit, "a clean cloud-init (exit 0) carries no degraded warning")
	assert.Contains(t, out.SSHCommand, "-J localhost:2223")
	assert.Contains(t, out.SSHCommand, "ubuntu@default.claude-abc",
		"hint must dial the namespaced <tenant>.<name>, not the bare name the gate rejects")

	require.Len(t, api.created, 1)
	req := api.created[0]
	// MCP pins no size defaults of its own: omitted vcpus/mem_mb/disk_gb reach
	// the control plane as zero and inherit its sole named defaults
	// (types.Default*, pinned in api_test.go), so the two layers cannot pin
	// divergent values.
	assert.Zero(t, req.VCPUs)
	assert.Zero(t, req.MemMB)
	assert.Zero(t, req.DiskGB)
	assert.Equal(t, "h1", req.HostID)
	assert.Empty(t, req.SSHAuthorizedKey, "no key injection under the CA model")

	require.Len(t, run.execs, 1)
	assert.Equal(t, []string{"claude-abc|cloud-init status --wait"}, run.execs, "addressed by name, not IP")
}

func TestCreateRetriesThroughPreSSHDBootWindow(t *testing.T) {
	// "ready" means the IP is allocated, NOT that the guest has booted sshd, so
	// the first SSH dials can fail while the guest is still coming up. Fail the
	// first two Exec attempts (pre-sshd) and succeed on the third; vm_create must
	// retry through the transient failures rather than give up.
	api := &fakeToolsAPI{phases: []string{"creating", "ready"}}
	run := &fakeRunner{out: ExecResult{ExitCode: 0}, execErrs: 2}
	tl := newTestTools(api, run)

	out, err := tl.VMCreate(t.Context(), VMCreateIn{})
	require.NoError(t, err)
	assert.Equal(t, "10.77.1.9", out.IP)
	require.Len(t, run.execs, 3, "two transient failures then a success")
}

func TestCreateRidesThroughTransientListErrors(t *testing.T) {
	// First two polls fail (control-plane blip), then the VM reports ready.
	api := &fakeToolsAPI{listErrs: 2, phases: []string{"creating", "ready"}}
	run := &fakeRunner{out: ExecResult{ExitCode: 0}}
	tl := newTestTools(api, run)

	out, err := tl.VMCreate(t.Context(), VMCreateIn{})
	require.NoError(t, err, "transient ListVMs errors must not abandon the wait")
	assert.Equal(t, "new1", out.ID)
	assert.Equal(t, "10.77.1.9", out.IP)
	require.Len(t, run.execs, 1)
	assert.Contains(t, run.execs[0], "cloud-init status --wait")
}

func TestCreateRetriesUntilSSHReachable(t *testing.T) {
	// VM reaches "ready" (IP allocated) but sshd is not up yet: the first two SSH
	// dials fail with a connection error, the third connects and cloud-init runs.
	api := &fakeToolsAPI{phases: []string{"creating", "ready"}}
	run := &fakeRunner{out: ExecResult{ExitCode: 0}, execErrs: 2}
	tl := newTestTools(api, run)

	out, err := tl.VMCreate(t.Context(), VMCreateIn{})
	require.NoError(t, err, "must ride out the pre-sshd window, not fail on the first dial")
	assert.Equal(t, "10.77.1.9", out.IP)
	require.Len(t, run.execs, 3, "two connection failures then a success")
	assert.Contains(t, run.execs[2], "cloud-init status --wait")
	assert.Empty(t, api.deleted, "spec: never auto-destroy")
}

func TestCreateSSHNeverReachableReports(t *testing.T) {
	// VM is "ready" but never becomes SSH-reachable within the budget.
	api := &fakeToolsAPI{phases: []string{"ready"}}
	run := &fakeRunner{err: fmt.Errorf("dial tcp 10.77.1.9:22: connect: no route to host")}
	tl := newTestTools(api, run)
	tl.WaitTimeout = 15 * time.Millisecond

	out, err := tl.VMCreate(t.Context(), VMCreateIn{})
	require.Error(t, err)
	assert.Contains(t, err.Error(), "never became SSH-reachable")
	assert.Contains(t, err.Error(), "new1", "degraded error must name the VM id so the model can destroy it")
	assert.Equal(t, "10.77.1.9", out.IP, "out stays populated so the model can act on it")
	assert.Empty(t, api.deleted, "spec: never auto-destroy on unreachable")
}

func TestCreateCloudInitExit2IsDegradedSuccess(t *testing.T) {
	// cloud-init exit 2 = "done, with recoverable errors": the guest is booted and
	// fully usable. vm_create must SUCCEED (no error) and surface the degradation as
	// a warning, so the model doesn't retry and leak the working VM.
	api := &fakeToolsAPI{phases: []string{"creating", "ready"}}
	run := &fakeRunner{out: ExecResult{ExitCode: 2, Stderr: "some unit failed"}}
	tl := newTestTools(api, run)

	out, err := tl.VMCreate(t.Context(), VMCreateIn{})
	require.NoError(t, err, "exit 2 is a usable VM, not a failed create")
	assert.Equal(t, "10.77.1.9", out.IP)
	assert.Contains(t, out.CloudInit, "degraded", "exit 2 must carry a degraded warning")
	assert.Contains(t, out.SSHCommand, "ubuntu@default.claude-abc", "a usable VM still gets its ssh hint")
	assert.Empty(t, api.deleted, "spec: never auto-destroy")
	require.Len(t, run.execs, 1, "a ran cloud-init must NOT be retried")
}

func TestCreateReportsCloudInitFailure(t *testing.T) {
	// SSH connects and cloud-init RUNS but exits with a non-recoverable code
	// (anything other than 0 or 2). Unlike a connection failure this must NOT be
	// retried: cloud-init already ran, so report the degraded VM after exactly one
	// Exec.
	for _, code := range []int{1, 3} {
		t.Run(fmt.Sprintf("exit%d", code), func(t *testing.T) {
			api := &fakeToolsAPI{phases: []string{"creating", "ready"}}
			run := &fakeRunner{out: ExecResult{ExitCode: code, Stderr: "boom"}}
			tl := newTestTools(api, run)

			out, err := tl.VMCreate(t.Context(), VMCreateIn{})
			require.Error(t, err)
			assert.Contains(t, err.Error(), fmt.Sprintf("cloud-init exited %d", code))
			assert.Contains(t, err.Error(), "new1", "error must name the VM id")
			assert.Contains(t, err.Error(), "claude-abc", "error must name the VM name")
			assert.Empty(t, out.CloudInit, "a hard cloud-init failure is an error, not a warning")
			assert.Equal(t, "10.77.1.9", out.IP, "out stays populated so the model can act on it")
			assert.Empty(t, api.deleted, "spec: never auto-destroy")
			require.Len(t, run.execs, 1, "a ran-but-failed cloud-init must NOT be retried")
		})
	}
}

func TestCreateNoWaitReturnsImmediately(t *testing.T) {
	api := &fakeToolsAPI{}
	run := &fakeRunner{}
	tl := newTestTools(api, run)
	no := false
	out, err := tl.VMCreate(t.Context(), VMCreateIn{Wait: &no})
	require.NoError(t, err)
	assert.Equal(t, "new1", out.ID)
	assert.Empty(t, out.IP)
	assert.Empty(t, run.execs)
}

// mixedFleet is a two-host fleet with a Mac second and a decommissioned-looking
// host offline, the shape placement has to get right. Only the Linux host
// advertises a named network — a Mac cannot serve one at all.
func mixedFleet() []client.Host {
	return []client.Host{
		{ID: "h1", Name: "onyx", Online: true, AgentVersion: currentAgent, HostNetworks: []string{"lan", "dmz"}},
		{ID: "h2", Name: "Squirtle.local", Online: true, AgentVersion: currentAgent},
		{ID: "h3", Name: "charmander", Online: false, AgentVersion: currentAgent},
	}
}

func TestCreatePlacesOnTheNamedHost(t *testing.T) {
	api := &fakeToolsAPI{hosts: mixedFleet()}
	tl := newTestTools(api, &fakeRunner{})
	no := false

	_, err := tl.VMCreate(t.Context(), VMCreateIn{Host: "Squirtle.local", Wait: &no})
	require.NoError(t, err)
	require.Len(t, api.created, 1)
	assert.Equal(t, "h2", api.created[0].HostID, "a named host is reachable past the first online one")
}

func TestCreatePlacesOnAHostNamedByID(t *testing.T) {
	api := &fakeToolsAPI{hosts: mixedFleet()}
	tl := newTestTools(api, &fakeRunner{})
	no := false

	_, err := tl.VMCreate(t.Context(), VMCreateIn{Host: "h2", Wait: &no})
	require.NoError(t, err)
	require.Len(t, api.created, 1)
	assert.Equal(t, "h2", api.created[0].HostID)
}

func TestCreateRefusesAnOfflineHost(t *testing.T) {
	api := &fakeToolsAPI{hosts: mixedFleet()}
	tl := newTestTools(api, &fakeRunner{})
	no := false

	_, err := tl.VMCreate(t.Context(), VMCreateIn{Host: "charmander", Wait: &no})
	require.Error(t, err)
	assert.ErrorContains(t, err, `host "charmander" is offline`)
	assert.Empty(t, api.created, "an offline host would strand the create")
}

func TestCreateUnknownHostNamesTheFleet(t *testing.T) {
	api := &fakeToolsAPI{hosts: mixedFleet()}
	tl := newTestTools(api, &fakeRunner{})
	no := false

	_, err := tl.VMCreate(t.Context(), VMCreateIn{Host: "mewtwo", Wait: &no})
	require.Error(t, err)
	assert.ErrorContains(t, err, `no host with id or name "mewtwo"`)
	assert.ErrorContains(t, err, "onyx (online)", "the error names what the fleet DOES have")
	assert.ErrorContains(t, err, "Squirtle.local (online)")
	assert.ErrorContains(t, err, "charmander (offline)", "with each host's state")
	assert.Empty(t, api.created)
}

func TestCreateWithoutAHostTakesTheFirstOnlineOne(t *testing.T) {
	// The first host is offline, so the default lands on the second.
	hosts := mixedFleet()
	hosts[0].Online = false
	api := &fakeToolsAPI{hosts: hosts}
	tl := newTestTools(api, &fakeRunner{})
	no := false

	_, err := tl.VMCreate(t.Context(), VMCreateIn{Wait: &no})
	require.NoError(t, err)
	require.Len(t, api.created, 1)
	assert.Equal(t, "h2", api.created[0].HostID)
}

// The network the caller names has to reach the API as a network — it is the
// whole request, and nothing downstream can infer it.
func TestCreateAsksForTheNamedNetwork(t *testing.T) {
	api := &fakeToolsAPI{hosts: mixedFleet()}
	tl := newTestTools(api, &fakeRunner{})
	no := false

	out, err := tl.VMCreate(t.Context(), VMCreateIn{Network: "lan", Wait: &no})
	require.NoError(t, err)
	require.Len(t, api.created, 1)
	assert.Equal(t, "lan", api.created[0].Network)
	assert.Equal(t, "lan", out.Network, "the accepted network is reported back without waiting for anything")
}

// A create that names no network asks for none: an empty field must stay empty
// on the wire rather than becoming some default underlay name.
func TestCreateWithoutANetworkAsksForNone(t *testing.T) {
	api := &fakeToolsAPI{hosts: mixedFleet()}
	tl := newTestTools(api, &fakeRunner{})
	no := false

	out, err := tl.VMCreate(t.Context(), VMCreateIn{Wait: &no})
	require.NoError(t, err)
	require.Len(t, api.created, 1)
	assert.Empty(t, api.created[0].Network)
	assert.Empty(t, out.Network)
	assert.Empty(t, out.NetworkIP)
}

// Both addresses come back: the host-fabric one the ssh command uses, and the
// one the named network's own DHCP granted.
func TestCreateReportsBothAddresses(t *testing.T) {
	api := &fakeToolsAPI{hosts: mixedFleet(), phases: []string{"creating", "ready"}, network: "lan", networkIP: "192.168.0.232"}
	tl := newTestTools(api, &fakeRunner{out: ExecResult{ExitCode: 0}})

	out, err := tl.VMCreate(t.Context(), VMCreateIn{Network: "lan"})
	require.NoError(t, err)
	assert.Equal(t, "10.77.1.9", out.IP, "the host-fabric address is still the one ssh reaches it at")
	assert.Equal(t, "lan", out.Network)
	assert.Equal(t, "192.168.0.232", out.NetworkIP)
}

// Two sources can name the network: what was asked for, and what the server
// recorded. Once the record exists it wins — the echo is only what was asked,
// and it would quietly lie the day the server normalizes a stored name.
func TestCreateReportsTheServersRecordOfTheNetworkOverTheAsk(t *testing.T) {
	// The host is named, so placement does not filter on the asked-for spelling
	// and the request reaches the server exactly as written.
	api := &fakeToolsAPI{hosts: mixedFleet(), phases: []string{"ready"}, network: "lan", networkIP: "192.168.0.232"}
	tl := newTestTools(api, &fakeRunner{out: ExecResult{ExitCode: 0}})

	out, err := tl.VMCreate(t.Context(), VMCreateIn{Host: "onyx", Network: "LAN"})
	require.NoError(t, err)
	require.Len(t, api.created, 1)
	assert.Equal(t, "LAN", api.created[0].Network, "the ask travels unaltered")
	assert.Equal(t, "lan", out.Network, "but the answer is the server's record")
}

// Readiness is the guest booting. A site's DHCP server owes eitri no schedule,
// so a lease that has not landed by then must not hold the create open or fail
// it — the VM is up and usable, and network_ip is simply not known yet.
func TestCreateSucceedsWithTheNetworkLeaseStillOutstanding(t *testing.T) {
	api := &fakeToolsAPI{hosts: mixedFleet(), phases: []string{"creating", "ready"}, network: "lan"} // networkIP never arrives
	tl := newTestTools(api, &fakeRunner{out: ExecResult{ExitCode: 0}})

	out, err := tl.VMCreate(t.Context(), VMCreateIn{Network: "lan"})
	require.NoError(t, err)
	assert.Equal(t, "10.77.1.9", out.IP)
	assert.Equal(t, "lan", out.Network)
	assert.Empty(t, out.NetworkIP, "an unanswered lease is an empty field, not a failed create")
}

// Default placement is a free choice, so it is made among hosts that can
// actually serve what was asked for: picking the first eligible host and
// letting the control plane refuse it would manufacture a failure.
func TestCreateWithoutAHostPlacesOnOneServingTheNetwork(t *testing.T) {
	hosts := mixedFleet()
	hosts[0].HostNetworks = nil             // onyx, first and otherwise eligible, serves none
	hosts[1].HostNetworks = []string{"lan"} // the second one does
	api := &fakeToolsAPI{hosts: hosts}
	tl := newTestTools(api, &fakeRunner{})
	no := false

	_, err := tl.VMCreate(t.Context(), VMCreateIn{Network: "lan", Wait: &no})
	require.NoError(t, err)
	require.Len(t, api.created, 1)
	assert.Equal(t, "h2", api.created[0].HostID)
}

// With nothing in the fleet serving it, the refusal names what IS served —
// the one place an MCP caller learns the fleet's network names without
// provoking a refusal from the control plane.
func TestCreateWithNoHostForTheNetworkNamesWhatIsAdvertised(t *testing.T) {
	api := &fakeToolsAPI{hosts: mixedFleet()}
	tl := newTestTools(api, &fakeRunner{})
	no := false

	_, err := tl.VMCreate(t.Context(), VMCreateIn{Network: "storage", Wait: &no})
	require.Error(t, err)
	assert.ErrorContains(t, err, `no eligible host advertises network "storage"`)
	assert.ErrorContains(t, err, "onyx: lan, dmz", "the error names what the fleet DOES advertise")
	assert.ErrorContains(t, err, "Squirtle.local: none")
	assert.NotContains(t, err.Error(), "charmander", "an offline host has not been asked what it serves")
	assert.ErrorContains(t, err, "--host-network storage=<bridge>", "with the remedy in the operator's own vocabulary")
	assert.ErrorContains(t, err, "NAT underlay")
	assert.Empty(t, api.created, "nothing is created on a host that cannot serve the request")
}

// A caller who names both a host and a network gets that host: the control
// plane owns the pairing and refuses it in one message. Re-deciding it here
// would put a second copy of the rule in front of the first.
func TestCreateOnANamedHostLeavesTheNetworkToTheControlPlane(t *testing.T) {
	api := &fakeToolsAPI{hosts: mixedFleet()}
	tl := newTestTools(api, &fakeRunner{})
	no := false

	_, err := tl.VMCreate(t.Context(), VMCreateIn{Host: "Squirtle.local", Network: "lan", Wait: &no})
	require.NoError(t, err)
	require.Len(t, api.created, 1)
	assert.Equal(t, "h2", api.created[0].HostID)
	assert.Equal(t, "lan", api.created[0].Network, "the request travels as asked, for the server to judge")
}

func TestCreateWaitTimeoutDoesNotDestroy(t *testing.T) {
	api := &fakeToolsAPI{phases: []string{"creating"}} // never ready
	run := &fakeRunner{}
	tl := newTestTools(api, run)
	tl.WaitTimeout = 10 * time.Millisecond
	_, err := tl.VMCreate(t.Context(), VMCreateIn{})
	require.Error(t, err)
	assert.Contains(t, err.Error(), "creating") // current phase reported
	assert.Empty(t, api.deleted, "spec: never auto-destroy on timeout")
}

func TestExecResolvesNameAndFormatsResult(t *testing.T) {
	api := &fakeToolsAPI{vms: []client.VM{{ID: "abc123", Name: "web-1", Lifecycle: "ready", AssignedIP: "10.77.1.5"}}}
	run := &fakeRunner{out: ExecResult{Stdout: "ok\n", ExitCode: 0}}
	tl := newTestTools(api, run)

	out, err := tl.VMExec(t.Context(), VMExecIn{VM: "web-1", Command: "echo ok"})
	require.NoError(t, err)
	assert.Equal(t, 0, out.ExitCode)
	assert.Equal(t, "ok\n", out.Stdout)
	assert.Equal(t, []string{"web-1|echo ok"}, run.execs, "addressed by name, not IP")
}

func TestExecUnknownVM(t *testing.T) {
	tl := newTestTools(&fakeToolsAPI{}, &fakeRunner{})
	_, err := tl.VMExec(t.Context(), VMExecIn{VM: "nope", Command: "x"})
	assert.ErrorContains(t, err, "no VM with id or name")
}

func TestExecNotReadyVMRejectedWithoutCallingRunner(t *testing.T) {
	for _, lifecycle := range []string{"creating", "stopped", "failed", "deleting", ""} {
		t.Run(lifecycle, func(t *testing.T) {
			api := &fakeToolsAPI{vms: []client.VM{{ID: "abc123", Name: "web-1", Lifecycle: lifecycle}}}
			run := &fakeRunner{}
			tl := newTestTools(api, run)

			_, err := tl.VMExec(t.Context(), VMExecIn{VM: "web-1", Command: "echo ok"})
			require.Error(t, err)
			assert.ErrorContains(t, err, "not ready")
			assert.Empty(t, run.execs, "runner must not be called on a non-ready VM")
		})
	}
}

func TestWriteFileNotReadyVMRejectedWithoutCallingRunner(t *testing.T) {
	api := &fakeToolsAPI{vms: []client.VM{{ID: "abc123", Name: "web-1", Lifecycle: "creating"}}}
	run := &fakeRunner{}
	tl := newTestTools(api, run)

	_, err := tl.VMWriteFile(t.Context(), VMWriteFileIn{VM: "web-1", Path: "/app/x", Content: "data"})
	require.Error(t, err)
	assert.ErrorContains(t, err, "not ready")
	assert.Empty(t, run.files, "runner must not be called on a non-ready VM")
}

func TestReadFileNotReadyVMRejectedWithoutCallingRunner(t *testing.T) {
	api := &fakeToolsAPI{vms: []client.VM{{ID: "abc123", Name: "web-1", Lifecycle: "stopped"}}}
	run := &fakeRunner{files: map[string][]byte{"/app/x": []byte("data")}}
	tl := newTestTools(api, run)

	_, err := tl.VMReadFile(t.Context(), VMReadFileIn{VM: "web-1", Path: "/app/x"})
	require.Error(t, err)
	assert.ErrorContains(t, err, "not ready")
	assert.Empty(t, run.reads, "runner must not be called on a non-ready VM")
}

// vm_list and vm_info hand back the API's own VM, so a guest's second address
// is already in both. This pins that: flattening either into a hand-written
// view is exactly how a field goes missing from the surface that is supposed to
// be the programmatic one.
func TestListAndInfoCarryBothAddresses(t *testing.T) {
	api := &fakeToolsAPI{vms: []client.VM{{
		ID: "abc123", Name: "web-1", Lifecycle: "ready",
		AssignedIP: "10.77.1.5", Network: "lan", NetworkIP: "192.168.0.232",
	}}}
	tl := newTestTools(api, &fakeRunner{})

	listed, err := tl.VMList(t.Context(), VMListIn{})
	require.NoError(t, err)
	require.Len(t, listed.VMs, 1)
	assert.Equal(t, "lan", listed.VMs[0].Network)
	assert.Equal(t, "192.168.0.232", listed.VMs[0].NetworkIP)
	assert.Equal(t, "10.77.1.5", listed.VMs[0].AssignedIP)

	info, err := tl.VMInfo(t.Context(), VMInfoIn{VM: "web-1"})
	require.NoError(t, err)
	assert.Equal(t, "lan", info.VM.Network)
	assert.Equal(t, "192.168.0.232", info.VM.NetworkIP, "vm_info is where a caller reads a lease that landed late")
}

// TestVMInfoHintNamespacesConnectName pins that vm_info's ssh_command hint dials
// the gate by the <tenant>.<name> connect name (the gate rejects a bare name),
// and that the gateless deployment still emits the plain `ssh <user>@<name>`.
func TestVMInfoHintNamespacesConnectName(t *testing.T) {
	api := &fakeToolsAPI{vms: []client.VM{{ID: "abc123", Name: "web-1", Lifecycle: "ready", AssignedIP: "10.77.1.5"}}}

	// Gate configured: hint must be the namespaced -J form.
	tl := newTestTools(api, &fakeRunner{})
	out, err := tl.VMInfo(t.Context(), VMInfoIn{VM: "web-1"})
	require.NoError(t, err)
	assert.Contains(t, out.SSHCommand, "-J localhost:2223")
	assert.Contains(t, out.SSHCommand, "ubuntu@default.web-1",
		"gate hint must dial <tenant>.<name>, not the bare name")
	assert.NotContains(t, out.SSHCommand, "ubuntu@web-1",
		"hint must not contain the bare name the gate rejects")

	// Gateless deployment: the direct `ssh <user>@<name>` form is unchanged.
	tl.Gate = ""
	out, err = tl.VMInfo(t.Context(), VMInfoIn{VM: "web-1"})
	require.NoError(t, err)
	assert.Equal(t, "ssh ubuntu@web-1", out.SSHCommand)
}

func TestDestroyRequiresExactMatch(t *testing.T) {
	api := &fakeToolsAPI{vms: []client.VM{{ID: "abc123", Name: "web-1"}}}
	tl := newTestTools(api, &fakeRunner{})
	_, err := tl.VMDestroy(t.Context(), VMDestroyIn{VM: "web"})
	require.Error(t, err)
	out, err := tl.VMDestroy(t.Context(), VMDestroyIn{VM: "web-1"})
	require.NoError(t, err)
	assert.Equal(t, "abc123", out.ID)
	assert.Equal(t, []string{"abc123"}, api.deleted)
}

func TestExposePublishesGuestPortWithDialAddress(t *testing.T) {
	api := &fakeToolsAPI{vms: []client.VM{{ID: "abc123", Name: "web-1", Lifecycle: "ready", AssignedIP: "10.77.1.5"}}}
	tl := newTestTools(api, &fakeRunner{})

	out, err := tl.VMExpose(t.Context(), VMExposeIn{VM: "web-1", GuestPort: 8080})
	require.NoError(t, err)
	assert.Equal(t, int64(8080), out.Exposure.GuestPort)
	assert.Equal(t, int64(30000), out.Exposure.HostPort, "an omitted host port is allocated from the reserved range")
	assert.Equal(t, "10.0.0.4:30000", out.Exposure.Address, "the address joins the host's own address with the bound port")
	assert.Equal(t, "pending", out.Exposure.State)

	// A named host port is passed through to the control plane unchanged.
	out, err = tl.VMExpose(t.Context(), VMExposeIn{VM: "abc123", GuestPort: 5432, HostPort: 31500})
	require.NoError(t, err)
	assert.Equal(t, int64(31500), out.Exposure.HostPort)
	assert.Equal(t, "10.0.0.4:31500", out.Exposure.Address)
}

func TestExposeCarriesTheProtocolAndUnexposePicksByIt(t *testing.T) {
	api := &fakeToolsAPI{vms: []client.VM{{ID: "abc123", Name: "web-1", Lifecycle: "ready"}}}
	tl := newTestTools(api, &fakeRunner{})

	tcp, err := tl.VMExpose(t.Context(), VMExposeIn{VM: "web-1", GuestPort: 53, HostPort: 30053})
	require.NoError(t, err)
	assert.Equal(t, "tcp", tcp.Exposure.Protocol, "an unnamed protocol is tcp, and the view says which it got")

	udp, err := tl.VMExpose(t.Context(), VMExposeIn{VM: "web-1", GuestPort: 53, HostPort: 30053, Protocol: "udp"})
	require.NoError(t, err)
	assert.Equal(t, "udp", udp.Exposure.Protocol)

	// One guest port, both protocols: the same ambiguity two host ports create,
	// and protocol is the other way to resolve it.
	_, err = tl.VMUnexpose(t.Context(), VMUnexposeIn{VM: "web-1", GuestPort: 53})
	require.Error(t, err)
	assert.ErrorContains(t, err, "53:30053/udp", "the error names the protocol of what IS published")
	assert.Empty(t, api.revoked)

	out, err := tl.VMUnexpose(t.Context(), VMUnexposeIn{VM: "web-1", GuestPort: 53, Protocol: "udp"})
	require.NoError(t, err)
	assert.Equal(t, udp.Exposure.ID, out.ID)
	assert.Equal(t, "udp", out.Protocol)
	assert.Equal(t, []string{udp.Exposure.ID}, api.revoked)
}

func TestExposeUnknownVM(t *testing.T) {
	tl := newTestTools(&fakeToolsAPI{}, &fakeRunner{})
	_, err := tl.VMExpose(t.Context(), VMExposeIn{VM: "nope", GuestPort: 80})
	assert.ErrorContains(t, err, "no VM with id or name")
}

func TestExposuresListsPublishedPorts(t *testing.T) {
	api := &fakeToolsAPI{
		vms: []client.VM{{ID: "abc123", Name: "web-1", Lifecycle: "ready"}},
		// A host that has not reported its uplink yields no address: the grant
		// exists before there is anywhere to name.
		exposures: map[string][]client.Exposure{"abc123": {
			{ID: "x-1", GuestPort: 80, HostPort: 30000, HostAddr: "10.0.0.4", State: "active"},
			{ID: "x-2", GuestPort: 443, HostPort: 30001, State: "failed", Reason: "address already in use"},
		}},
	}
	tl := newTestTools(api, &fakeRunner{})

	out, err := tl.VMExposures(t.Context(), VMExposuresIn{VM: "web-1"})
	require.NoError(t, err)
	require.Len(t, out.Exposures, 2)
	assert.Equal(t, "10.0.0.4:30000", out.Exposures[0].Address)
	assert.Equal(t, "active", out.Exposures[0].State)
	assert.Empty(t, out.Exposures[1].Address, "no host address yet means no address to dial")
	assert.Equal(t, "address already in use", out.Exposures[1].Reason)

	// A VM that publishes nothing lists nothing, not an error.
	api.vms = append(api.vms, client.VM{ID: "def456", Name: "quiet-1", Lifecycle: "ready"})
	out, err = tl.VMExposures(t.Context(), VMExposuresIn{VM: "quiet-1"})
	require.NoError(t, err)
	assert.Empty(t, out.Exposures)
}

func TestUnexposeResolvesTheExposureByGuestPort(t *testing.T) {
	api := &fakeToolsAPI{
		vms: []client.VM{{ID: "abc123", Name: "web-1", Lifecycle: "ready"}},
		exposures: map[string][]client.Exposure{"abc123": {
			{ID: "x-1", GuestPort: 80, HostPort: 30000, HostAddr: "10.0.0.4"},
			{ID: "x-2", GuestPort: 443, HostPort: 30001, HostAddr: "10.0.0.4"},
		}},
	}
	tl := newTestTools(api, &fakeRunner{})

	out, err := tl.VMUnexpose(t.Context(), VMUnexposeIn{VM: "web-1", GuestPort: 443})
	require.NoError(t, err)
	assert.Equal(t, "x-2", out.ID, "the caller names a guest port; the id is resolved for it")
	assert.Equal(t, int64(443), out.GuestPort)
	assert.Equal(t, int64(30001), out.HostPort)
	assert.Equal(t, []string{"x-2"}, api.revoked)
}

func TestUnexposeUnpublishedPortNamesWhatIsPublished(t *testing.T) {
	api := &fakeToolsAPI{
		vms: []client.VM{{ID: "abc123", Name: "web-1", Lifecycle: "ready"}},
		exposures: map[string][]client.Exposure{"abc123": {
			{ID: "x-1", GuestPort: 80, HostPort: 30000},
		}},
	}
	tl := newTestTools(api, &fakeRunner{})

	_, err := tl.VMUnexpose(t.Context(), VMUnexposeIn{VM: "web-1", GuestPort: 8080})
	require.Error(t, err)
	assert.ErrorContains(t, err, "publishes no guest port 8080")
	assert.ErrorContains(t, err, "80:30000", "the error names what IS published")
	assert.Empty(t, api.revoked, "a miss revokes nothing")

	// A VM publishing nothing at all says so rather than naming an empty set.
	api.exposures = nil
	_, err = tl.VMUnexpose(t.Context(), VMUnexposeIn{VM: "web-1", GuestPort: 80})
	assert.ErrorContains(t, err, "none")
}

func TestUnexposeAmbiguousGuestPortRefusesUntilHostPortNamed(t *testing.T) {
	// Nothing stops one guest port being published on two host ports. Closing
	// one of them is a guess, so refuse and say how to choose.
	api := &fakeToolsAPI{
		vms: []client.VM{{ID: "abc123", Name: "web-1", Lifecycle: "ready"}},
		exposures: map[string][]client.Exposure{"abc123": {
			{ID: "x-1", GuestPort: 80, HostPort: 30000},
			{ID: "x-2", GuestPort: 80, HostPort: 31000},
		}},
	}
	tl := newTestTools(api, &fakeRunner{})

	_, err := tl.VMUnexpose(t.Context(), VMUnexposeIn{VM: "web-1", GuestPort: 80})
	require.Error(t, err)
	assert.ErrorContains(t, err, "name host_port")
	assert.Empty(t, api.revoked, "an ambiguous match closes no listener")

	out, err := tl.VMUnexpose(t.Context(), VMUnexposeIn{VM: "web-1", GuestPort: 80, HostPort: 31000})
	require.NoError(t, err)
	assert.Equal(t, "x-2", out.ID)
	assert.Equal(t, []string{"x-2"}, api.revoked)
}

func TestWriteAndReadFileTools(t *testing.T) {
	api := &fakeToolsAPI{vms: []client.VM{{ID: "abc123", Name: "web-1", Lifecycle: "ready", AssignedIP: "10.77.1.5"}}}
	run := &fakeRunner{}
	tl := newTestTools(api, run)

	_, err := tl.VMWriteFile(t.Context(), VMWriteFileIn{VM: "abc123", Path: "/app/x", Content: "data"})
	require.NoError(t, err)
	rd, err := tl.VMReadFile(t.Context(), VMReadFileIn{VM: "web-1", Path: "/app/x"})
	require.NoError(t, err)
	assert.Equal(t, "data", rd.Content)
}

// ── ca_upload ────────────────────────────────────────────────────────────────

// caLine is a real ed25519 public key in authorized_keys form.
func caLine(t *testing.T) string {
	t.Helper()
	pub, _, err := ed25519.GenerateKey(rand.Reader)
	require.NoError(t, err)
	sp, err := ssh.NewPublicKey(pub)
	require.NoError(t, err)
	return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sp))) + " alex@laptop"
}

// TestCAUploadRegistersThePublicHalf: the line reaches the API unchanged, the
// label rides with it, and the answer names the CA that was registered and the
// consequence of having registered it now rather than earlier.
func TestCAUploadRegistersThePublicHalf(t *testing.T) {
	api := &fakeToolsAPI{}
	tools := &Tools{API: api}
	line := caLine(t)

	out, err := tools.CAUpload(t.Context(), CAUploadIn{PublicKey: line, Label: "laptop"})
	require.NoError(t, err)

	require.Len(t, api.cas, 1)
	assert.Equal(t, line, api.cas[0][0], "the key must reach the API byte for byte")
	assert.Equal(t, "laptop", api.cas[0][1])
	assert.True(t, strings.HasPrefix(out.Fingerprint, "SHA256:"), "got %q", out.Fingerprint)
	assert.Equal(t, "laptop", out.Label)
	assert.Contains(t, out.Note, "created BEFORE this upload will not")
}

// TestCAUploadRefusesWhatIsNotACAPublicKey: a bad line is a tool error naming
// the problem, not a 400 the model has to interpret — and a certificate is the
// mistake worth naming, since it looks like a key and is not one.
func TestCAUploadRefusesWhatIsNotACAPublicKey(t *testing.T) {
	api := &fakeToolsAPI{}
	tools := &Tools{API: api}

	_, err := tools.CAUpload(t.Context(), CAUploadIn{})
	require.Error(t, err)
	assert.Contains(t, err.Error(), "public_key is required")

	_, err = tools.CAUpload(t.Context(), CAUploadIn{PublicKey: "not a key"})
	require.Error(t, err)
	assert.Contains(t, err.Error(), "not an SSH public key line")

	_, err = tools.CAUpload(t.Context(), CAUploadIn{PublicKey: certLine(t)})
	require.Error(t, err)
	assert.Contains(t, err.Error(), "certificate, not a CA public key")

	assert.Empty(t, api.cas, "nothing reaches the API until the line parses")
}

// certLine builds a user certificate, the thing most easily confused for a key.
func certLine(t *testing.T) string {
	t.Helper()
	_, priv, err := ed25519.GenerateKey(rand.Reader)
	require.NoError(t, err)
	signer, err := ssh.NewSignerFromSigner(priv)
	require.NoError(t, err)
	cert := &ssh.Certificate{Key: signer.PublicKey(), CertType: ssh.UserCert,
		ValidPrincipals: []string{"ubuntu"}, ValidBefore: ssh.CertTimeInfinity}
	require.NoError(t, cert.SignCert(rand.Reader, signer))
	return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(cert)))
}

// TestCAUploadSurfacesARefusal: the API's no reaches the model as a tool error.
func TestCAUploadSurfacesARefusal(t *testing.T) {
	api := &fakeToolsAPI{caErr: errors.New("forbidden")}
	tools := &Tools{API: api}

	_, err := tools.CAUpload(t.Context(), CAUploadIn{PublicKey: caLine(t)})
	require.Error(t, err)
	assert.Contains(t, err.Error(), "registering the CA with your tenant")
}

// ── tenant_info ──────────────────────────────────────────────────────────────

// TestTenantInfoReportsSetupWithoutKeyMaterial: the answer identifies each CA
// and says whether eitri can currently reach anything — and carries no key.
func TestTenantInfoReportsSetupWithoutKeyMaterial(t *testing.T) {
	api := &fakeToolsAPI{
		userCAs: []client.UserCA{
			{Fingerprint: "SHA256:aaa", Label: "laptop", PubKey: "ssh-ed25519 AAAASECRETLOOKING alex"},
		},
		delegation: &client.Delegation{
			KeyID: "eitri-delegation", Principals: []string{"ubuntu"}, ExpiresAt: "2026-08-09T00:00:00Z",
		},
	}
	tools := &Tools{API: api, Gate: "gate.eitri.sh:2222"}

	out, err := tools.TenantInfo(t.Context(), TenantInfoIn{})
	require.NoError(t, err)

	require.Len(t, out.CAs, 1)
	assert.Equal(t, "SHA256:aaa", out.CAs[0].Fingerprint)
	assert.Equal(t, "laptop", out.CAs[0].Label)
	assert.True(t, out.Delegated)
	require.NotNil(t, out.Delegation)
	assert.Equal(t, []string{"ubuntu"}, out.Delegation.Principals)
	assert.Equal(t, "2026-08-09T00:00:00Z", out.Delegation.ExpiresAt)
	assert.Equal(t, "gate.eitri.sh:2222", out.Gate)

	raw, err := json.Marshal(out)
	require.NoError(t, err)
	assert.NotContains(t, string(raw), "AAAASECRETLOOKING", "tenant_info describes CAs, it does not reproduce them")
}

// TestTenantInfoReportsNoDelegationAsData: not having delegated is the common
// state and the thing a caller most needs told, so it is a field rather than an
// error to parse.
func TestTenantInfoReportsNoDelegationAsData(t *testing.T) {
	tools := &Tools{API: &fakeToolsAPI{}}

	out, err := tools.TenantInfo(t.Context(), TenantInfoIn{})
	require.NoError(t, err)
	assert.False(t, out.Delegated)
	assert.Nil(t, out.Delegation)
	assert.Empty(t, out.CAs)
}

// TestTenantInfoSurfacesAReadFailure: the CA list is the part that must work.
func TestTenantInfoSurfacesAReadFailure(t *testing.T) {
	tools := &Tools{API: &fakeToolsAPI{listCAErr: errors.New("unauthorized")}}

	_, err := tools.TenantInfo(t.Context(), TenantInfoIn{})
	require.Error(t, err)
	assert.Contains(t, err.Error(), "reading this tenant's registered CAs")
}