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