a73x

internal/mcpserver/api_test.go

Ref:   Size: 12.5 KiB   History

package mcpserver

import (
	"crypto/ed25519"
	"crypto/rand"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"

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

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

func fakeAPI(t *testing.T, handler http.HandlerFunc) API {
	t.Helper()
	srv := httptest.NewServer(handler)
	t.Cleanup(srv.Close)
	return API{Client: &client.Client{BaseURL: srv.URL, Token: "tok123"}}
}

func TestListVMs(t *testing.T) {
	c := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) {
		assert.Equal(t, "GET", r.Method)
		assert.Equal(t, "/api/v1/vms", r.URL.Path)
		assert.Equal(t, "Bearer tok123", r.Header.Get("Authorization"))
		json.NewEncoder(w).Encode([]map[string]any{
			{"id": "abc", "name": "claude-x", "lifecycle": "ready", "assigned_ip": "10.77.1.5", "host_id": "h1"},
		})
	})
	vms, err := c.ListVMs(t.Context())
	require.NoError(t, err)
	require.Len(t, vms, 1)
	assert.Equal(t, client.VM{ID: "abc", Name: "claude-x", Lifecycle: "ready", AssignedIP: "10.77.1.5", HostID: "h1"}, vms[0])
}

func TestCreateVMSendsRequestAndParsesID(t *testing.T) {
	c := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) {
		assert.Equal(t, "POST", r.Method)
		assert.Equal(t, "/api/v1/vms", r.URL.Path)
		var req map[string]any
		if !assert.NoError(t, json.NewDecoder(r.Body).Decode(&req)) {
			return
		}
		assert.NotContains(t, req, "persistent", "the retired field must not go on the wire — the server refuses a body carrying it")
		assert.Equal(t, "h1", req["host_id"])
		assert.Equal(t, "ssh-ed25519 AAA test", req["ssh_authorized_key"])
		json.NewEncoder(w).Encode(map[string]string{"id": "new1", "name": req["name"].(string)})
	})
	got, err := c.CreateVM(t.Context(), client.CreateVMRequest{
		HostID: "h1", Name: "claude-abc123", VCPUs: 2, MemMB: 2048, DiskGB: 20,
		SSHAuthorizedKey: "ssh-ed25519 AAA test",
	})
	require.NoError(t, err)
	assert.Equal(t, "new1", got.ID)
}

func TestDeleteVM(t *testing.T) {
	c := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) {
		assert.Equal(t, "DELETE", r.Method)
		assert.Equal(t, "/api/v1/vms/abc", r.URL.Path)
		w.WriteHeader(http.StatusNoContent)
	})
	require.NoError(t, c.DeleteVM(t.Context(), "abc"))
}

func TestFirstEligibleHost(t *testing.T) {
	c := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) {
		json.NewEncoder(w).Encode([]map[string]any{
			{"id": "h0", "name": "down", "online": false, "agent_version": "v0.0.5"},
			{"id": "h1", "name": "mewtwo", "online": true, "agent_version": "v0.0.5"},
		})
	})
	h, err := c.FirstEligibleHost(t.Context(), "")
	require.NoError(t, err)
	assert.Equal(t, "h1", h.ID)
}

// A host whose agent predates certified host keys is not a placement candidate:
// the control plane would refuse the create, so picking it only moves the
// failure later. The next host that can certify takes the VM instead.
func TestFirstEligibleHostSkipsPreCSRAgents(t *testing.T) {
	c := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) {
		json.NewEncoder(w).Encode([]map[string]any{
			{"id": "h0", "name": "old", "online": true, "agent_version": "v0.0.3"},
			{"id": "h1", "name": "unstamped", "online": true, "agent_version": "dev"},
			{"id": "h2", "name": "quiet", "online": true},
			{"id": "h3", "name": "mewtwo", "online": true, "agent_version": "v0.0.4"},
		})
	})
	h, err := c.FirstEligibleHost(t.Context(), "")
	require.NoError(t, err)
	assert.Equal(t, "h3", h.ID)
}

// With every online host ruled out, the refusal names the reason and the fix —
// "no online hosts" would send the caller looking for hardware that is up.
func TestFirstEligibleHostReportsWhyNoneAreEligible(t *testing.T) {
	c := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) {
		json.NewEncoder(w).Encode([]map[string]any{
			{"id": "h0", "name": "old", "online": true, "agent_version": "v0.0.3"},
			{"id": "h1", "name": "older", "online": true, "agent_version": "v0.0.1"},
		})
	})
	_, err := c.FirstEligibleHost(t.Context(), "")
	require.Error(t, err)
	assert.Contains(t, err.Error(), "2 online host(s)")
	assert.Contains(t, err.Error(), "predate certified host keys (v0.0.4)")
	assert.Contains(t, err.Error(), "upgrade-agent")
	assert.NotContains(t, err.Error(), "no online hosts")
}

// Asking for a named network narrows placement to hosts that advertise it: the
// first eligible host is only the default when nothing further was asked for.
func TestFirstEligibleHostPrefersOneAdvertisingTheNetwork(t *testing.T) {
	c := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) {
		json.NewEncoder(w).Encode([]map[string]any{
			{"id": "h1", "name": "onyx", "online": true, "agent_version": "v0.0.6"},
			{"id": "h2", "name": "mewtwo", "online": true, "agent_version": "v0.0.6", "host_networks": []string{"lan"}},
		})
	})
	h, err := c.FirstEligibleHost(t.Context(), "lan")
	require.NoError(t, err)
	assert.Equal(t, "h2", h.ID)

	// Without the ask, the first eligible host still wins.
	h, err = c.FirstEligibleHost(t.Context(), "")
	require.NoError(t, err)
	assert.Equal(t, "h1", h.ID)
}

// The refusal has to teach: an agent that cannot see the fleet learns the
// network names from here, and the operator learns the flag that adds one.
func TestFirstEligibleHostForAnUnservedNetworkNamesTheServedOnes(t *testing.T) {
	c := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) {
		json.NewEncoder(w).Encode([]map[string]any{
			{"id": "h1", "name": "onyx", "online": true, "agent_version": "v0.0.6", "host_networks": []string{"lan"}},
			{"id": "h2", "name": "gone", "online": false, "agent_version": "v0.0.6", "host_networks": []string{"storage"}},
			{"id": "h3", "name": "ancient", "online": true, "agent_version": "v0.0.3", "host_networks": []string{"storage"}},
		})
	})
	_, err := c.FirstEligibleHost(t.Context(), "storage")
	require.Error(t, err)
	assert.Contains(t, err.Error(), `no eligible host advertises network "storage"`)
	assert.Contains(t, err.Error(), "onyx: lan")
	assert.NotContains(t, err.Error(), "gone", "an offline host's advertised networks are stale registry state, not an offer")
	assert.NotContains(t, err.Error(), "ancient",
		"a host that would refuse the create over its agent version is not an offer either — naming it sends the caller to configure a bridge when the remedy is an upgrade")
	assert.Contains(t, err.Error(), "--host-network storage=<bridge>")
}

// An agent too old to certify host keys is ruled out before its networks are
// ever consulted, and the refusal is the upgrade one: the remedy is the agent,
// not the bridge.
func TestFirstEligibleHostReportsThePreCSRAgentEvenWhenANetworkWasAsked(t *testing.T) {
	c := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) {
		json.NewEncoder(w).Encode([]map[string]any{
			{"id": "h1", "name": "old", "online": true, "agent_version": "v0.0.3", "host_networks": []string{"lan"}},
		})
	})
	_, err := c.FirstEligibleHost(t.Context(), "lan")
	require.Error(t, err)
	assert.Contains(t, err.Error(), "predate certified host keys")
	assert.NotContains(t, err.Error(), "advertises network")
}

// A fleet with nothing up still says exactly that.
func TestFirstEligibleHostWithNothingOnline(t *testing.T) {
	c := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) {
		json.NewEncoder(w).Encode([]map[string]any{
			{"id": "h0", "name": "down", "online": false, "agent_version": "v0.0.5"},
		})
	})
	_, err := c.FirstEligibleHost(t.Context(), "")
	require.ErrorContains(t, err, "no online hosts")
}

// The control plane's create refusal is the whole story the model needs, so it
// must survive the trip through the client and out of vm_create — a bare "409"
// would leave it with nothing to act on.
func TestVMCreateSurfacesThePreCSRRefusal(t *testing.T) {
	const refusal = "host mewtwo (h1) runs agent v0.0.3, which predates certified host keys (v0.0.4): " +
		"a guest created there gets no certified host key, so nothing could verify it and it would be " +
		"unreachable through the gate. Upgrade that host's agent — the console's upgrade button, or POST " +
		"http://plane/api/v1/hosts/h1/upgrade-agent — then retry."
	c := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path == "/api/v1/hosts" {
			json.NewEncoder(w).Encode([]map[string]any{
				{"id": "h1", "name": "mewtwo", "online": true, "agent_version": "v0.0.5"},
			})
			return
		}
		http.Error(w, refusal, http.StatusConflict)
	})
	tools := &Tools{API: c}
	_, err := tools.VMCreate(t.Context(), VMCreateIn{Host: "mewtwo"})
	require.Error(t, err)
	assert.Contains(t, err.Error(), "predates certified host keys")
	assert.Contains(t, err.Error(), "Upgrade that host's agent")
}

// The same is true of the capacity refusal, and more so: the numbers ARE the
// remedy. A model told only "409" would retry the same too-large VM; one told
// which dimension bound, and by how much, can ask for a size that fits.
func TestVMCreateSurfacesTheCapacityRefusal(t *testing.T) {
	const refusal = "host mewtwo (h1) has no room for this VM: memory — needs 8192MB, already holds 4096 of 6144MB. " +
		"That is a limit its operator set below what the machine has, so the agent there would refuse this VM " +
		"at boot and it would sit failed. Ask for less, delete a VM on that host to free what it holds, " +
		"or create it on a host with room."
	c := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path == "/api/v1/hosts" {
			json.NewEncoder(w).Encode([]map[string]any{
				{"id": "h1", "name": "mewtwo", "online": true, "agent_version": "v0.0.5"},
			})
			return
		}
		http.Error(w, refusal, http.StatusConflict)
	})
	tools := &Tools{API: c}
	_, err := tools.VMCreate(t.Context(), VMCreateIn{Host: "mewtwo", MemMB: 8192})
	require.Error(t, err)
	assert.Contains(t, err.Error(), "has no room for this VM")
	assert.Contains(t, err.Error(), "memory — needs 8192MB, already holds 4096 of 6144MB")
	assert.Contains(t, err.Error(), "create it on a host with room")
}

func TestAPIErrorSurfacesBodyNotToken(t *testing.T) {
	c := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) {
		http.Error(w, "invalid name", http.StatusBadRequest)
	})
	_, err := c.CreateVM(t.Context(), client.CreateVMRequest{})
	require.Error(t, err)
	assert.Contains(t, err.Error(), "invalid name")
	assert.NotContains(t, err.Error(), "tok123", "token must never leak into errors")
}

// genTestKey returns a freshly generated ed25519 ssh.PublicKey.
func genTestKey(t *testing.T) ssh.PublicKey {
	t.Helper()
	pub, _, err := ed25519.GenerateKey(rand.Reader)
	require.NoError(t, err)
	sshPub, err := ssh.NewPublicKey(pub)
	require.NoError(t, err)
	return sshPub
}

func TestFetchSSHCA(t *testing.T) {
	caPub := genTestKey(t)
	caLine := string(ssh.MarshalAuthorizedKey(caPub))
	c := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) {
		assert.Equal(t, "GET", r.Method)
		assert.Equal(t, "/api/v1/ssh-ca", r.URL.Path)
		assert.Equal(t, "Bearer tok123", r.Header.Get("Authorization"))
		json.NewEncoder(w).Encode(map[string]string{"ca": caLine})
	})
	got, err := c.FetchSSHCA(t.Context())
	require.NoError(t, err)
	assert.Equal(t, caPub.Marshal(), got.Marshal())
}

func TestFetchSSHCANotEnabled(t *testing.T) {
	c := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) {
		assert.Equal(t, "Bearer tok123", r.Header.Get("Authorization"))
		http.Error(w, "ssh jump gate not enabled", http.StatusNotFound)
	})
	_, err := c.FetchSSHCA(t.Context())
	require.Error(t, err)
	assert.NotContains(t, err.Error(), "404", "raw status code must not be surfaced")
	assert.NotContains(t, err.Error(), "tok123", "token must never leak into errors")
	assert.Contains(t, err.Error(), "not enabled", "error should clearly explain the gate is off")
}

func TestUploadUserCA(t *testing.T) {
	caLine := string(ssh.MarshalAuthorizedKey(genTestKey(t)))
	caLine = strings.TrimSpace(caLine)

	var gotBody map[string]string
	c := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) {
		assert.Equal(t, "POST", r.Method)
		assert.Equal(t, "/api/v1/tenants/default/user-cas", r.URL.Path)
		assert.Equal(t, "Bearer tok123", r.Header.Get("Authorization"))
		if !assert.NoError(t, json.NewDecoder(r.Body).Decode(&gotBody)) {
			return
		}
		w.WriteHeader(http.StatusNoContent)
	})

	err := c.UploadUserCA(t.Context(), "default", caLine)
	require.NoError(t, err)
	assert.Equal(t, caLine, gotBody["public_key"], "must POST the CA public key")
}

func TestUploadUserCAErrorDoesNotLeakToken(t *testing.T) {
	c := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) {
		http.Error(w, "internal error", http.StatusInternalServerError)
	})
	err := c.UploadUserCA(t.Context(), "default", "ssh-ed25519 AAAA")
	require.Error(t, err)
	assert.NotContains(t, err.Error(), "tok123", "token must never leak into errors")
}